commit 565484c22d76d1a4fecf5de45642a13929c12816 Author: venbatechnologies@gmail.com Date: Mon Jul 5 14:17:23 2021 +0530 Modified initial commit diff --git a/.bra.toml b/.bra.toml new file mode 100644 index 0000000..2e222e4 --- /dev/null +++ b/.bra.toml @@ -0,0 +1,19 @@ +[run] +init_cmds = [ + ["go", "run", "build.go", "-dev", "build-cli"], + ["go", "run", "build.go", "-dev", "build-server"], + ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] +] +watch_all = true +follow_symlinks = true +watch_dirs = [ + "$WORKDIR/pkg", + "$WORKDIR/public/views", + "$WORKDIR/conf", +] +watch_exts = [".go", ".ini", ".toml", ".template.html"] +build_delay = 1500 +cmds = [ + ["go", "run", "build.go", "-dev", "build-server"], + ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] +] diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..f7b0acd --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,15 @@ +[dev] +last 1 chrome versions +last 1 firefox versions +last 1 safari versions + +[production] +last 2 Firefox versions +last 2 Chrome versions +last 2 Safari versions +last 2 Edge versions +last 1 ios_saf versions +last 1 and_chr versions +last 1 samsung versions + + diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..96b949d --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,60 @@ +version: 2.1 + +aliases: + # Workflow filters + - &filter-only-main + branches: + only: main + +jobs: + scan-docker-image: + description: "Scans a docker image for vulnerabilities using trivy" + parameters: + image: + type: string + tag: + type: string + docker: + - image: circleci/buildpack-deps:stretch + steps: + - setup_remote_docker + - restore_cache: + key: vulnerability-db + - run: + name: Install trivy + command: | + VERSION=$( + curl --silent "https://api.github.com/repos/aquasecurity/trivy/releases/latest" | \ + grep '"tag_name":' | \ + sed -E 's/.*"v([^"]+)".*/\1/' + ) + + wget https://github.com/aquasecurity/trivy/releases/download/v${VERSION}/trivy_${VERSION}_Linux-64bit.tar.gz + tar zxvf trivy_${VERSION}_Linux-64bit.tar.gz + sudo mv trivy /usr/local/bin + - run: + name: Clear trivy cache + command: trivy --clear-cache + - run: + name: Scan Docker image for unkown/low/medium vulnerabilities + command: trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM << parameters.image >>:<< parameters.tag >> + - run: + name: Scan Docker image for high/critical vulnerabilities + command: trivy --exit-code 1 --severity HIGH,CRITICAL << parameters.image >>:<< parameters.tag >> + - save_cache: + key: vulnerability-db + paths: + - $HOME/.cache/trivy + +workflows: + nightly: + triggers: + - schedule: + cron: "0 0 * * *" + filters: *filter-only-main + jobs: + - scan-docker-image: + matrix: + parameters: + image: [grafana/grafana, grafana/grafana-enterprise] + tag: [latest, main, latest-ubuntu, main-ubuntu] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c535fa4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.awcache +.dockerignore +.git +.gitignore +.github +.vscode +bin +data* +dist +docker +Dockerfile +docs +dump.rdb +node_modules +/local +/tmp +*.yml +*.md diff --git a/.drone.star b/.drone.star new file mode 100644 index 0000000..bac6e56 --- /dev/null +++ b/.drone.star @@ -0,0 +1,10 @@ +load('scripts/pr.star', 'pr_pipelines') +load('scripts/main.star', 'main_pipelines') +load('scripts/release.star', 'release_pipelines', 'test_release_pipelines') +load('scripts/version.star', 'version_branch_pipelines') +load('scripts/vault.star', 'secrets') + +def main(ctx): + edition = 'oss' + return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ + test_release_pipelines() + version_branch_pipelines() + secrets() diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..506649e --- /dev/null +++ b/.drone.yml @@ -0,0 +1,3451 @@ +--- +kind: pipeline +type: docker +name: test-pr + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --variants linux-x64,linux-x64-musl,osx64,win64 --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + +- name: package + image: grafana/build-container:1.4.1 + commands: + - . scripts/build/gpg-test-vars.sh && ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --variants linux-x64,linux-x64-musl,osx64,win64 + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: build-frontend-docs + image: grafana/build-container:1.4.1 + commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend + +- name: build-docs-website + image: grafana/docs-base:latest + commands: + - mkdir -p /hugo/content/docs/grafana + - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ + - cd /hugo && make prod + failure: ignore + depends_on: + - initialize + - build-frontend-docs + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + archs: amd64 + dry_run: true + edition: oss + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + event: + - pull_request + +--- +kind: pipeline +type: docker +name: build-main + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: trigger-enterprise-downstream + image: grafana/drone-downstream + settings: + params: + - SOURCE_BUILD_NUMBER=${DRONE_BUILD_NUMBER} + - SOURCE_COMMIT=${DRONE_COMMIT} + repositories: + - grafana/grafana-enterprise@main + server: https://drone.grafana.net + token: + from_secret: drone_token + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: publish-frontend-metrics + image: grafana/build-container:1.4.1 + commands: + - ./scripts/ci-frontend-metrics.sh | ./bin/grabpl publish-metrics $${GRAFANA_MISC_STATS_API_KEY} + environment: + GRAFANA_MISC_STATS_API_KEY: + from_secret: grafana_misc_stats_api_key + failure: ignore + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: publish-storybook + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://grafana-storybook/canary + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - build-storybook + - end-to-end-tests + +- name: build-frontend-docs + image: grafana/build-container:1.4.1 + commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + ubuntu: true + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: release-canary-npm-packages + image: grafana/build-container:1.4.1 + commands: + - ./scripts/circle-release-canary-packages.sh + environment: + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token + depends_on: + - end-to-end-tests + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + branch: + - main + event: + - push + +--- +kind: pipeline +type: docker +name: windows-main + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition oss --build-id $$env:DRONE_BUILD_NUMBER + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads/oss/main/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads/oss/main/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + branch: + - main + event: + - push + +depends_on: +- build-main + +--- +kind: pipeline +type: docker +name: publish-main + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: publish-packages-oss + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id ${DRONE_BUILD_NUMBER} + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +trigger: + branch: + - main + event: + - push + +depends_on: +- build-main +- windows-main + +--- +kind: pipeline +type: docker +name: notify-main + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + branch: + - main + event: + - push + status: + - failure + +depends_on: +- build-main +- windows-main +- publish-main + +--- +kind: pipeline +type: docker +name: oss-build-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version ${DRONE_TAG} + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition oss --no-pull-enterprise ${DRONE_TAG} + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version ${DRONE_TAG} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + ubuntu: true + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: publish-storybook + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://grafana-storybook/latest + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://grafana-storybook/${DRONE_TAG} + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - build-storybook + - end-to-end-tests + +- name: release-npm-packages + image: grafana/build-container:1.4.1 + commands: + - ./scripts/build/release-packages.sh ${DRONE_TAG} + environment: + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token + NPM_TOKEN: + from_secret: npm_token + depends_on: + - publish-storybook + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + ref: + - refs/tags/v* + +--- +kind: pipeline +type: docker +name: oss-windows-release + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition oss ${DRONE_TAG} + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads/oss/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads/oss/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + ref: + - refs/tags/v* + +depends_on: +- oss-build-release + +--- +kind: pipeline +type: docker +name: enterprise-build-release + +platform: + os: linux + arch: amd64 + +clone: + disable: true + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: clone + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise ${DRONE_TAG} + - mkdir bin + - mv /tmp/grabpl bin/ + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version ${DRONE_TAG} + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + depends_on: + - clone + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition enterprise --no-pull-enterprise ${DRONE_TAG} + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: test-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise2 + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + +- name: lint-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise2 + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend-enterprise2 + +- name: build-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend-enterprise2 + - test-backend-enterprise2 + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version ${DRONE_TAG} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - build-backend-enterprise2 + - test-backend-enterprise2 + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz + PORT: 3001 + RUNDIR: e2e/tmp-grafana-enterprise + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: enterprise + password: + from_secret: docker_password + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: enterprise + password: + from_secret: docker_password + ubuntu: true + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: redis-integration-tests + image: grafana/build-container:1.4.1 + commands: + - ./bin/dockerize -wait tcp://redis:6379/0 -timeout 120s + - ./bin/grabpl integration-tests + environment: + REDIS_URL: redis://redis:6379/0 + depends_on: + - test-backend + - test-frontend + +- name: memcached-integration-tests + image: grafana/build-container:1.4.1 + commands: + - ./bin/dockerize -wait tcp://memcached:11211 -timeout 120s + - ./bin/grabpl integration-tests + environment: + MEMCACHED_HOSTS: memcached:11211 + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + - redis-integration-tests + - memcached-integration-tests + +- name: package-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-server-enterprise2 + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz + PORT: 3002 + RUNDIR: e2e/tmp-grafana-enterprise2 + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-enterprise2 + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3002 --tries 3 + environment: + HOST: end-to-end-tests-server-enterprise2 + depends_on: + - end-to-end-tests-server-enterprise2 + +- name: upload-packages-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - end-to-end-tests-enterprise2 + - mysql-integration-tests + - postgres-integration-tests + - redis-integration-tests + - memcached-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +- name: redis + image: redis:6.2.1-alpine + +- name: memcached + image: memcached:1.6.9-alpine + +image_pull_secrets: +- dockerconfigjson + +trigger: + ref: + - refs/tags/v* + +--- +kind: pipeline +type: docker +name: enterprise-windows-release + +platform: + os: windows + arch: amd64 + version: 1809 + +clone: + disable: true + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: clone + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - cp -r grafana-enterprise C:\App\grafana-enterprise + - rm -r -force grafana-enterprise + - cp grabpl.exe C:\App\grabpl.exe + - rm -force grabpl.exe + - C:\App\grabpl.exe init-enterprise C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe + - .\grabpl.exe verify-drone + depends_on: + - clone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition enterprise ${DRONE_TAG} + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads/enterprise/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads/enterprise/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +image_pull_secrets: +- dockerconfigjson + +trigger: + ref: + - refs/tags/v* + +depends_on: +- enterprise-build-release + +--- +kind: pipeline +type: docker +name: publish-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version ${DRONE_TAG} + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: publish-packages-oss + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json ${DRONE_TAG} + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +- name: publish-packages-enterprise + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json ${DRONE_TAG} + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +trigger: + ref: + - refs/tags/v* + +depends_on: +- oss-build-release +- oss-windows-release +- enterprise-build-release +- enterprise-windows-release + +--- +kind: pipeline +type: docker +name: notify-release + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + ref: + - refs/tags/v* + status: + - failure + +depends_on: +- oss-build-release +- oss-windows-release +- enterprise-build-release +- enterprise-windows-release +- publish-release + +--- +kind: pipeline +type: docker +name: oss-build-test-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version v7.3.0-test + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition oss --no-pull-enterprise v7.3.0-test + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version v7.3.0-test + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads-test + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: publish-storybook + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - echo Testing release + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - build-storybook + - end-to-end-tests + +- name: release-npm-packages + image: grafana/build-container:1.4.1 + environment: + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token + NPM_TOKEN: + from_secret: npm_token + depends_on: + - publish-storybook + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + event: + - custom + +--- +kind: pipeline +type: docker +name: oss-windows-test-release + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition oss --packages-bucket grafana-downloads-test v7.3.0-test + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads-test/oss/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads-test/oss/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + event: + - custom + +depends_on: +- oss-build-test-release + +--- +kind: pipeline +type: docker +name: enterprise-build-test-release + +platform: + os: linux + arch: amd64 + +clone: + disable: true + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: clone + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout main + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - mkdir bin + - mv /tmp/grabpl bin/ + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version v7.3.0-test + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + depends_on: + - clone + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition enterprise --no-pull-enterprise v7.3.0-test + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: test-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise2 + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + +- name: lint-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise2 + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend-enterprise2 + +- name: build-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend-enterprise2 + - test-backend-enterprise2 + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version v7.3.0-test + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - build-backend-enterprise2 + - test-backend-enterprise2 + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz + PORT: 3001 + RUNDIR: e2e/tmp-grafana-enterprise + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: redis-integration-tests + image: grafana/build-container:1.4.1 + commands: + - ./bin/dockerize -wait tcp://redis:6379/0 -timeout 120s + - ./bin/grabpl integration-tests + environment: + REDIS_URL: redis://redis:6379/0 + depends_on: + - test-backend + - test-frontend + +- name: memcached-integration-tests + image: grafana/build-container:1.4.1 + commands: + - ./bin/dockerize -wait tcp://memcached:11211 -timeout 120s + - ./bin/grabpl integration-tests + environment: + MEMCACHED_HOSTS: memcached:11211 + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads-test + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + - redis-integration-tests + - memcached-integration-tests + +- name: package-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-server-enterprise2 + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz + PORT: 3002 + RUNDIR: e2e/tmp-grafana-enterprise2 + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-enterprise2 + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3002 --tries 3 + environment: + HOST: end-to-end-tests-server-enterprise2 + depends_on: + - end-to-end-tests-server-enterprise2 + +- name: upload-packages-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-test + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - end-to-end-tests-enterprise2 + - mysql-integration-tests + - postgres-integration-tests + - redis-integration-tests + - memcached-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +- name: redis + image: redis:6.2.1-alpine + +- name: memcached + image: memcached:1.6.9-alpine + +image_pull_secrets: +- dockerconfigjson + +trigger: + event: + - custom + +--- +kind: pipeline +type: docker +name: enterprise-windows-test-release + +platform: + os: windows + arch: amd64 + version: 1809 + +clone: + disable: true + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: clone + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout main + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - cp -r grafana-enterprise C:\App\grafana-enterprise + - rm -r -force grafana-enterprise + - cp grabpl.exe C:\App\grabpl.exe + - rm -force grabpl.exe + - C:\App\grabpl.exe init-enterprise C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe + - .\grabpl.exe verify-drone + depends_on: + - clone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition enterprise --packages-bucket grafana-downloads-test v7.3.0-test + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads-test/enterprise/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads-test/enterprise/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +image_pull_secrets: +- dockerconfigjson + +trigger: + event: + - custom + +depends_on: +- enterprise-build-test-release + +--- +kind: pipeline +type: docker +name: publish-test-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version v7.3.0-test + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: publish-packages-oss + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +- name: publish-packages-enterprise + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +trigger: + event: + - custom + +depends_on: +- oss-build-test-release +- oss-windows-test-release +- enterprise-build-test-release +- enterprise-windows-test-release + +--- +kind: pipeline +type: docker +name: notify-test-release + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + event: + - custom + status: + - failure + +depends_on: +- oss-build-test-release +- oss-windows-test-release +- enterprise-build-test-release +- enterprise-windows-test-release +- publish-test-release + +--- +kind: pipeline +type: docker +name: oss-build-release-branch + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + ref: + - refs/heads/v* + +--- +kind: pipeline +type: docker +name: oss-windows-release-branch + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + ref: + - refs/heads/v* + +depends_on: +- oss-build-release-branch + +--- +kind: pipeline +type: docker +name: enterprise-build-release-branch + +platform: + os: linux + arch: amd64 + +clone: + disable: true + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: clone + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/grabpl + - chmod +x bin/grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_BRANCH} + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - mkdir bin + - mv /tmp/grabpl bin/ + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + depends_on: + - clone + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: validate-scuemata + image: grafana/build-container:1.4.1 + commands: + - ./bin/linux-amd64/grafana-cli cue validate-schema + depends_on: + - build-backend + +- name: test-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise2 + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + +- name: lint-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise2 + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + - test-backend-enterprise2 + +- name: build-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} --variants linux-x64 --no-pull-enterprise + depends_on: + - initialize + - lint-backend-enterprise2 + - test-backend-enterprise2 + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - build-backend-enterprise2 + - test-backend-enterprise2 + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz + PORT: 3001 + RUNDIR: e2e/tmp-grafana-enterprise + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: redis-integration-tests + image: grafana/build-container:1.4.1 + commands: + - ./bin/dockerize -wait tcp://redis:6379/0 -timeout 120s + - ./bin/grabpl integration-tests + environment: + REDIS_URL: redis://redis:6379/0 + depends_on: + - test-backend + - test-frontend + +- name: memcached-integration-tests + image: grafana/build-container:1.4.1 + commands: + - ./bin/dockerize -wait tcp://memcached:11211 -timeout 120s + - ./bin/grabpl integration-tests + environment: + MEMCACHED_HOSTS: memcached:11211 + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + - redis-integration-tests + - memcached-integration-tests + +- name: package-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --variants linux-x64 --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-server-enterprise2 + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz + PORT: 3002 + RUNDIR: e2e/tmp-grafana-enterprise2 + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-enterprise2 + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3002 --tries 3 + environment: + HOST: end-to-end-tests-server-enterprise2 + depends_on: + - end-to-end-tests-server-enterprise2 + +- name: upload-packages-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - end-to-end-tests-enterprise2 + - mysql-integration-tests + - postgres-integration-tests + - redis-integration-tests + - memcached-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +- name: redis + image: redis:6.2.1-alpine + +- name: memcached + image: memcached:1.6.9-alpine + +image_pull_secrets: +- dockerconfigjson + +trigger: + ref: + - refs/heads/v* + +--- +kind: pipeline +type: docker +name: enterprise-windows-release-branch + +platform: + os: windows + arch: amd64 + version: 1809 + +clone: + disable: true + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: clone + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.0.0/windows/grabpl.exe -OutFile grabpl.exe + - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout $$env:DRONE_BRANCH + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - cp -r grafana-enterprise C:\App\grafana-enterprise + - rm -r -force grafana-enterprise + - cp grabpl.exe C:\App\grabpl.exe + - rm -force grabpl.exe + - C:\App\grabpl.exe init-enterprise C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe + - .\grabpl.exe verify-drone + depends_on: + - clone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +image_pull_secrets: +- dockerconfigjson + +trigger: + ref: + - refs/heads/v* + +depends_on: +- enterprise-build-release-branch + +--- +kind: pipeline +type: docker +name: notify-release-branch + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + ref: + - refs/heads/v* + status: + - failure + +depends_on: +- oss-build-release-branch +- oss-windows-release-branch +- enterprise-build-release-branch +- enterprise-windows-release-branch + +--- +kind: secret +name: dockerconfigjson + +get: + path: secret/data/common/gcr + name: .dockerconfigjson + +--- +kind: secret +name: github_token + +get: + path: infra/data/ci/github/grafanabot + name: pat + +... diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3a8c0c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 + +[*.go] +indent_style = tab +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{js,ts,tsx,scss}] +quote_type = single + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab +indent_size = 2 + +[*.star] +indent_size = 4 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..82f41c2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +node_modules +compiled +build +vendor +devenv +data +dist +e2e/tmp diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..56189b5 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,28 @@ +{ + "extends": ["@grafana/eslint-config"], + "root": true, + "plugins": ["no-only-tests", "@emotion", "lodash"], + "rules": { + "no-only-tests/no-only-tests": "error", + "react/prop-types": "off", + "@emotion/jsx-import": "error", + "lodash/import-scope": [2, "member"] + }, + "overrides": [ + { + "files": ["packages/grafana-ui/src/components/uPlot/**/*.{ts,tsx}"], + "rules": { + "react-hooks/rules-of-hooks": "off", + "react-hooks/exhaustive-deps": "off" + } + }, + { + "files": ["packages/grafana-ui/src/components/ThemeDemos/**/*.{ts,tsx}"], + "rules": { + "@emotion/jsx-import": "off", + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3fba17e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,93 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +# Documentation owner: Diana Payton +/docs/ @grafana/docs-squad +/contribute/ @marcusolsson @grafana/docs-squad +/docs/sources/developers/plugins/ @marcusolsson @grafana/docs-squad +/docs/sources/enterprise/ @osg-grafana @grafana/docs-squad + +# Backend code +*.go @grafana/backend-platform +go.mod @grafana/backend-platform +go.sum @grafana/backend-platform + +# Cloud Datasources backend code +/pkg/tsdb/cloudwatch @grafana/cloud-datasources @grafana/observability-squad +/pkg/tsdb/azuremonitor @grafana/cloud-datasources +/pkg/tsdb/cloudmonitoring @grafana/cloud-datasources + +# Observability backend code +/pkg/tsdb/influxdb @grafana/observability-squad +/pkg/tsdb/elasticsearch @grafana/observability-squad +/pkg/tsdb/graphite @grafana/observability-squad +/pkg/tsdb/jaeger @grafana/observability-squad +/pkg/tsdb/loki @grafana/observability-squad +/pkg/tsdb/zipkin @grafana/observability-squad +/pkg/tsdb/tempo @grafana/observability-squad + +# Unified Alerting +/pkg/services/ngalert @grafana/alerting-squad +/pkg/services/sqlstore/migrations/ualert @grafana/alerting-squad + +# Database migrations +/pkg/services/sqlstore/migrations @grafana/backend-platform @grafana/hosted-grafana-team +*_mig.go @grafana/backend-platform @grafana/hosted-grafana-team + +# Backend code docs +/contribute/style-guides/backend.md @grafana/backend-platform +/contribute/architecture/backend @grafana/backend-platform +/contribute/engineering/backend @grafana/backend-platform + +/e2e @grafana/grafana-frontend-platform +/packages @grafana/grafana-frontend-platform +/plugins-bundled @grafana/grafana-frontend-platform +/public @grafana/grafana-frontend-platform +/scripts/build/release-packages.sh @grafana/grafana-frontend-platform +/scripts/circle-release-next-packages.sh @grafana/grafana-frontend-platform +/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform +/scripts/grunt @grafana/grafana-frontend-platform +/scripts/webpack @grafana/grafana-frontend-platform +package.json @grafana/grafana-frontend-platform +tsconfig.json @grafana/grafana-frontend-platform +lerna.json @grafana/grafana-frontend-platform +.babelrc @grafana/grafana-frontend-platform +.prettierrc.js @grafana/grafana-frontend-platform +.eslintrc @grafana/grafana-frontend-platform + +# @grafana/ui component documentation +*.mdx @marcusolsson @jessover9000 @grafana/grafana-frontend-platform + +/public/app/features/explore/ @grafana/observability-squad +/packages/jaeger-ui-components/ @grafana/observability-squad + +# Core datasources +/public/app/plugins/datasource/cloudwatch @grafana/cloud-datasources @grafana/observability-squad +/public/app/plugins/datasource/elasticsearch @grafana/observability-squad +/public/app/plugins/datasource/grafana-azure-monitor-datasource @grafana/cloud-datasources +/public/app/plugins/datasource/graphite @grafana/observability-squad +/public/app/plugins/datasource/influxdb @grafana/observability-squad +/public/app/plugins/datasource/jaeger @grafana/observability-squad +/public/app/plugins/datasource/loki @grafana/observability-squad +/public/app/plugins/datasource/mssql @grafana/backend-platform +/public/app/plugins/datasource/mysql @grafana/backend-platform +/public/app/plugins/datasource/opentsdb @grafana/backend-platform +/public/app/plugins/datasource/postgres @grafana/backend-platform +/public/app/plugins/datasource/prometheus @grafana/observability-squad +/public/app/plugins/datasource/cloud-monitoring @grafana/cloud-datasources +/public/app/plugins/datasource/zipkin @grafana/observability-squad +/public/app/plugins/datasource/tempo @grafana/observability-squad +/public/app/plugins/datasource/alertmanager @grafana/alerting-squad + +# Cloud middleware +/grafana-mixin/ @grafana/cloud-middleware diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.md b/.github/ISSUE_TEMPLATE/1-bug_report.md new file mode 100644 index 0000000..9bec0f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Report a bug you found when using Grafana +labels: 'type: bug' +--- + + + +**What happened**: + +**What you expected to happen**: + +**How to reproduce it (as minimally and precisely as possible)**: + +**Anything else we need to know?**: + +**Environment**: +- Grafana version: +- Data source type & version: +- OS Grafana is installed on: +- User OS & Browser: +- Grafana plugins: +- Others: diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.md b/.github/ISSUE_TEMPLATE/2-feature_request.md new file mode 100644 index 0000000..ccb9f3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature_request.md @@ -0,0 +1,11 @@ +--- +name: Enhancement request +about: Suggest an enhancement or new feature for the Grafana project +labels: 'type: feature request' +--- + + + +**What would you like to be added**: + +**Why is this needed**: diff --git a/.github/ISSUE_TEMPLATE/3-accessibility.md b/.github/ISSUE_TEMPLATE/3-accessibility.md new file mode 100644 index 0000000..51b3ecc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-accessibility.md @@ -0,0 +1,26 @@ +--- +name: Accessibility issue +about: Help make Grafana be better at keyboard navigation, screen-readable and accessible to all. +labels: 'type: accessibility' +--- + + + +**Steps to reproduce**: + +**Actual Result**: + +**Expected Result** + +**Relevant WCAG Criteria:** [#.#.# WCAG Criterion](link to https://www.w3.org/WAI/WCAG21/quickref/?versions=2.0) + +**Environment**: +- Grafana version: +- Data source type & version: +- User OS & Browser: +- Others: diff --git a/.github/ISSUE_TEMPLATE/4-grafana_ui_component.md b/.github/ISSUE_TEMPLATE/4-grafana_ui_component.md new file mode 100644 index 0000000..0ac30e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-grafana_ui_component.md @@ -0,0 +1,39 @@ +--- +name: '@grafana/ui component request' +about: Suggest a component for the @grafana/ui package +labels: 'area/grafana/ui' +--- + + + +**Why is this component needed**: + +___ + - [ ] Is/could it be used in more than one place in Grafana? + +**Where is/could it be used?**: + +___ +- [ ] Post screenshots possible. +- [ ] It has a single use case. +- [ ] It is/could be used in multiple places. + +**Implementation** (Checklist meant for the person implementing the component) + +- [ ] Component has a story in Storybook. +- [ ] Props and naming follows [our style guide](https://github.com/grafana/grafana/blob/main/contribute/style-guides/frontend.md). +- [ ] It is extendable (rest props are spread, styles with className work, and so on). +- [ ] Uses [theme for spacing, colors, and so on](https://github.com/grafana/grafana/blob/main/contribute/style-guides/themes.md). +- [ ] Works with both light and dark theme. + +**Documentation** + +- [ ] Properties are documented. +- [ ] Use cases are described. +- [ ] Code examples for the different use cases. +- [ ] Dos and don'ts. +- [ ] Styling guidelines, specific color usage (if applicable). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8211d8e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Help + url: https://community.grafana.com + about: Please ask and answer questions here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fdd84f2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ + + +**What this PR does / why we need it**: + +**Which issue(s) this PR fixes**: + + + +Fixes # + +**Special notes for your reviewer**: + diff --git a/.github/bot.md b/.github/bot.md new file mode 100644 index 0000000..c08f03d --- /dev/null +++ b/.github/bot.md @@ -0,0 +1,30 @@ +# GitHub & grafanabot automation + +The bot is configured via [commands.json](https://github.com/grafana/grafana/blob/main/.github/commands.json) and some other GitHub workflows [workflows](https://github.com/grafana/grafana/tree/main/.github/workflows). + +Comment commands: + +* Write the word `/duplicate #` anywhere in a comment and the bot will add the correct label and standard message. +* Write the word `/needsMoreInfo` anywhere in a comment and the bot will add the correct label and standard message. + +Label commands: + +* Add label `bot/question` the the bot will close with standard question message and add label `type/question` +* Add label `bot/duplicate` the the bot will close with standard duplicate message and add label `type/duplicate` +* Add label `bot/needs more info` for bot to request more info (or use comment command mentioned above) +* Add label `bot/close feature request` for bot to close a feature request with standard message and adds label `not implemented` +* Add label `bot/no new info` for bot to close an issue where we asked for more info but has not received any updates in at least 14 days. + +## Metrics + +Metrics are configured in [metrics-collector.json](https://github.com/grafana/grafana/blob/main/.github/metrics-collector.json) and are also defined in the +[metrics-collector](https://github.com/grafana/grafana-github-actions/blob/main/metrics-collector/index.ts) GitHub action. + +## Backport PR + +To automatically backport a PR to a release branch like v7.3.x add a label named `backport v7.3.x`. The label name should follow the pattern `backport `. Once merged grafanabot will automatically +try to cherry-pick the PR merge commit into that branch and open a PR. It will sync the milestone with the source PR so make sure the source PR also is assigned the milestone for the patch release. If the PR is already merged you can still add this label and trigger the backport automation. + +If there are merge conflicts the bot will write a comment on the source PR saying the cherry-pick failed. In this case you have to do the cherry pick and backport PR manually. + +The backport logic is written [here](https://github.com/grafana/grafana-github-actions/blob/main/backport/backport.ts) diff --git a/.github/commands.json b/.github/commands.json new file mode 100644 index 0000000..b86ea86 --- /dev/null +++ b/.github/commands.json @@ -0,0 +1,53 @@ +[ + { + "type": "label", + "name": "bot/question", + "addLabel": "type/question", + "removeLabel": "bot/question", + "action": "close", + "comment": "Please ask your question on [community.grafana.com/](https://community.grafana.com/). To avoid having your issue closed in the future, please read our [CONTRIBUTING](https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md) guidelines.\n\nHappy graphing!" + }, + { + "type": "comment", + "name": "duplicate", + "allowUsers": [], + "action": "updateLabels", + "addLabel": "type/duplicate" + }, + { + "type": "label", + "name": "bot/duplicate", + "addLabel": "type/duplicate", + "removeLabel": "bot/duplicate", + "action": "close", + "comment": "Thanks for creating this issue! It looks like this has already been reported by another user. We’ve closed this in favor of the existing one. Please consider adding any details you think is missing to that issue.\n\nTo avoid having your issue closed in the future, please read our [CONTRIBUTING](https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md) guidelines.\n\nHappy graphing!" + }, + { + "type": "comment", + "name": "needsMoreInfo", + "allowUsers": [], + "action": "updateLabels", + "addLabel": "bot/needs more info" + }, + { + "type": "label", + "name": "bot/needs more info", + "action": "updateLabels", + "addLabel": "needs more info", + "removeLabel": "bot/needs more info", + "comment": "Thanks for creating this issue! We think it's missing some basic information. \r\n\r\nFollow the issue template and add additional information that will help us replicate the problem. \r\nFor data visualization issues: \r\n- Query results from the inspect drawer (data tab & query inspector)\r\n- Panel settings can be extracted in the panel inspect drawer JSON tab\r\n\r\nFor dashboard related issues: \r\n- Dashboard JSON can be found in the dashboard settings JSON model view\r\n\r\nFor authentication, provisioning and alerting issues, Grafana server logs are useful. \r\n\r\nHappy graphing!" + }, + { + "type": "label", + "name": "bot/no new info", + "action": "close", + "comment": "We've closed this issue since it needs more information and hasn't had any activity recently. We can re-open it after you you add more information. To avoid having your issue closed in the future, please read our [CONTRIBUTING](https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md) guidelines.\n\nHappy graphing!" + }, + { + "type": "label", + "name": "bot/close feature request", + "action": "close", + "addLabel": "not implemented", + "comment": "This feature request has been open for a long time with few received upvotes or comments, so we are closing it. We're trying to limit open GitHub issues in order to better track planned work and features. \r\n\r\nThis doesn't mean that we'll never ever implement it or that we will never accept a PR for it. A closed issue can still attract upvotes and act as a ticket to track feature demand\/interest. \r\n\r\nThank You to you for taking the time to create this issue!" + } +] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5db8a10 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/metrics-collector.json b/.github/metrics-collector.json new file mode 100644 index 0000000..f301440 --- /dev/null +++ b/.github/metrics-collector.json @@ -0,0 +1,32 @@ +{ + "queries": [ + { + "name": "type_bug", + "query": "label:\"type/bug\" is:open" + }, + { + "name": "type_docs", + "query": "label:\"type/docs\" is:open" + }, + { + "name": "needs_investigation", + "query": "label:\"needs investigation\" is:open" + }, + { + "name": "needs_more_info", + "query": "label:\"needs more info\" is:open" + }, + { + "name": "unlabeled", + "query": "is:open is:issue no:label" + }, + { + "name": "open_prs", + "query": "is:open is:pr" + }, + { + "name": "milestone_7_4_open", + "query": "is:open is:issue milestone:7.4" + } + ] +} diff --git a/.github/pr-commands.json b/.github/pr-commands.json new file mode 100644 index 0000000..cb5bc2e --- /dev/null +++ b/.github/pr-commands.json @@ -0,0 +1,182 @@ +[ + { + "type": "changedfiles", + "matches": [ + "docs/**/*", + "contribute/**/*" + ], + "action": "updateLabel", + "addLabel": "type/docs" + }, + { + "type": "changedfiles", + "matches": [ + "public/**/*", + "packages/**/*", + "e2e/**/*", + "plugins-bundled/**/*", + "scripts/build/release-packages.sh", + "scripts/circle-release-next-packages.sh", + "scripts/ci-frontend-metrics.sh", + "scripts/grunt/**/*", + "scripts/webpack/**/*", + "package.json", + "tsconfig.json", + "lerna.json", + ".babelrc", + ".prettierrc.js", + ".eslintrc", + "**/*.mdx" + ], + "action": "updateLabel", + "addLabel": "area/frontend" + }, + { + "type": "changedfiles", + "matches": [ + "**/*.go", + "go.mod", + "go.sum", + "contribute/style-guides/backend.md", + "contribute/architecture/backend/**/*", + "scripts/go/**/*" + ], + "action": "updateLabel", + "addLabel": "area/backend" + }, + { + "type": "changedfiles", + "matches": [ + "pkg/services/sqlstore/migrations/**/*", + "**/*_mig.go" + ], + "action": "updateLabel", + "addLabel": "area/backend/db/migration" + }, + { + "type": "changedfiles", + "matches": [ "public/app/features/explore/**/*"], + "action": "updateLabel", + "addLabel": "area/explore" + }, + { + "type": "changedfiles", + "matches": [ + ".circleci/**/*", + "packaging/**/*", + "scripts/build/**/*", + "scripts/*.sh", + "scripts/*.star", + ".drone.star", + ".drone.yml", + "Makefile", + "Dockerfile", + "Dockerfile.ubuntu" + ], + "action": "updateLabel", + "addLabel": "type/build-packaging" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/grafana-azure-monitor-datasource/**/*", "pkg/tsdb/azuremonitor/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Azure" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/cloud-monitoring/**/*", "pkg/tsdb/cloudmonitoring/**/*"], + "action": "updateLabel", + "addLabel": "datasource/GoogleCloudMonitoring" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/cloudwatch/**/*", "pkg/tsdb/cloudwatch/**/*"], + "action": "updateLabel", + "addLabel": "datasource/CloudWatch" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/elasticsearch/**/*", "pkg/tsdb/elasticsearch/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Elasticsearch" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/graphite/**/*", "pkg/tsdb/graphite/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Graphite" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/influxdb/**/*", "pkg/tsdb/influx/**/*"], + "action": "updateLabel", + "addLabel": "datasource/InfluxDB" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/jaeger"], + "action": "updateLabel", + "addLabel": "datasource/Jaeger" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/loki/**/*", "pkg/tsdb/loki/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Loki" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/mssql/**/*", "pkg/tsdb/mssql/**/*"], + "action": "updateLabel", + "addLabel": "datasource/MSSQL" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/mysql/**/*", "pkg/tsdb/mysql/**/*"], + "action": "updateLabel", + "addLabel": "datasource/MySQL" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/opentsdb/**/*", "pkg/tsdb/opentsdb/**/*"], + "action": "updateLabel", + "addLabel": "datasource/OpenTSDB" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/postgres/**/*", "pkg/tsdb/postgres/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Postgres" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/prometheus/**/*", "pkg/tsdb/prometheus/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Prometheus" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/tempo/**/*", "pkg/tsdb/tempo/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Tempo" + }, + { + "type": "changedfiles", + "matches": [ "public/app/plugins/datasource/zipkin/**/*"], + "action": "updateLabel", + "addLabel": "datasource/Zipkin" + }, + { + "type": "changedfiles", + "matches": ["public/app/features/variables/**/*", "public/app/features/templating/**/*"], + "action": "updateLabel", + "addLabel": "area/dashboard/templating" + }, + { + "type": "author", + "name": "pr/external", + "notMemberOf": { "org": "grafana" }, + "action": "updateLabel", + "addLabel": "pr/external" + } +] diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..701b0b2 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,47 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# General configuration +# Label to use when marking as stale +staleLabel: stale + +# Pull request specific configuration +pulls: + # Number of days of inactivity before an Issue or Pull Request becomes stale + daysUntilStale: 14 + # Number of days of inactivity before a stale Issue or Pull Request is closed. + # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. + daysUntilClose: 30 + # Comment to post when marking as stale. Set to `false` to disable + markComment: > + This pull request has been automatically marked as stale because it has not had + activity in the last 2 weeks. It will be closed in 30 days if no further activity occurs. Please + feel free to give a status update now, ping for review, or re-open when it's ready. + Thank you for your contributions! + # Comment to post when closing a stale Issue or Pull Request. + closeComment: > + This pull request has been automatically closed because it has not had + activity in the last 30 days. Please feel free to give a status update now, ping for review, or re-open when it's ready. + Thank you for your contributions! + # Limit the number of actions per hour, from 1-30. Default is 30 + limitPerRun: 1 + +exemptLabels: + - help wanted + - type/bug + - type/feature-request + - Epic + - no stalebot + +# Issue specific configuration +issues: + limitPerRun: 1 + daysUntilStale: 100000 + daysUntilClose: 100000 + markComment: > + This issue has been automatically marked as stale because it has not had activity in the + last 100 days. It will be closed in the next 100 days if no activity occurs. + Thank you for your contributions. + closeComment: > + This issue has been automatically closed because it has not had activity in the + last month and a half. If this issue is still valid, please ping a maintainer and ask them to check this again. + Thank you for your contributions. diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000..55dc5c8 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,26 @@ +name: Backport PR Creator +on: + pull_request_target: + types: + - closed + - labeled + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run backport + uses: ./actions/backport + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + labelsToAdd: "backport" + title: "[{{base}}] {{originalTitle}}" diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..f84ef26 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,65 @@ +name: Bump version +on: + workflow_dispatch: + inputs: + version: + required: true + default: '7.x.x' +jobs: + main: + runs-on: ubuntu-latest + steps: + # This is a basic workflow to help you get started with Actions + - uses: actions-ecosystem/action-regex-match@v2 + id: regex-match + with: + text: ${{ github.event.inputs.version }} + regex: '^(\d+.\d+).\d+(?:-beta.\d+)?$' + + - name: Validate input version + if: ${{ steps.regex-match.outputs.match == '' }} + run: | + echo "The input version format is not correct, please respect:\ + major.minor.patch or major.minor.patch-beta.number format. \ + example: 7.4.3 or 7.4.3-beta.1" + exit 1 + - uses: actions/checkout@v2 + + - name: Set intermedia variables + id: intermedia + run: | + echo "::set-output name=short_ref::${GITHUB_REF#refs/*/}" + echo "::set-output name=check_passed::false" + echo "::set-output name=branch_name::v${{steps.regex-match.outputs.group1}}" + echo "::set-output name=branch_exist::$(git ls-remote --heads https://github.com/grafana/grafana.git v${{ steps.regex-match.outputs.group1 }}.x | wc -l)" + + - name: Check input version is aligned with branch(not main) + if: steps.intermedia.outputs.branch_exist != '0' && !contains(steps.intermedia.outputs.short_ref, steps.intermedia.outputs.branch_name) + run: | + echo " You need to run the workflow on branch v${{steps.regex-match.outputs.group1}}.x + exit 1 + + - name: Check input version is aligned with branch(main) + if: steps.intermedia.outputs.branch_exist == '0' && !contains(steps.intermedia.outputs.short_ref, 'main') + run: | + echo "When you want to deliver a new new minor version, you might want to create a new branch first \ + with naming convention v[major].[minor].x, and just run the workflow on that branch. \ + Run the workflow on main only when needed" + exit 1 + + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - uses: actions/setup-node@v2.1.5 + with: + node-version: '14' + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run bump version + uses: ./actions/bump-version + with: + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..034a18f --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,66 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: [main, v1.8.x, v2.0.x, v2.1.x, v2.6.x, v3.0.x, v3.1.x, v4.0.x, v4.1.x, v4.2.x, v4.3.x, v4.4.x, v4.5.x, v4.6.x, v4.7.x, v5.0.x, v5.1.x, v5.2.x, v5.3.x, v5.4.x, v6.0.x, v6.1.x, v6.2.x, v6.3.x, v6.4.x, v6.5.x, v6.6.x, v6.7.x, v7.0.x, v7.1.x, v7.2.x] + pull_request: + # The branches below must be a subset of the branches above + branches: [main] + schedule: + - cron: '0 4 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['javascript', 'go', 'python'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 0000000..91af697 --- /dev/null +++ b/.github/workflows/commands.yml @@ -0,0 +1,25 @@ +name: Run commands when issues are labeled or comments added +on: + issues: + types: [labeled] + issue_comment: + types: [created] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run Commands + uses: ./actions/commands + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + configPath: commands diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..9f1f1fe --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,24 @@ +name: Create or update GitHub release +on: + workflow_dispatch: + inputs: + version: + required: true + description: Needs to match, exactly, the name of a milestone (NO v prefix) +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run github release action + uses: ./actions/github-release + with: + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} diff --git a/.github/workflows/metrics-collector.yml b/.github/workflows/metrics-collector.yml new file mode 100644 index 0000000..84dec4e --- /dev/null +++ b/.github/workflows/metrics-collector.yml @@ -0,0 +1,35 @@ +# +# When triggered by the cron job it will also collect metrics for: +# * number of issues without label +# * number of issues with "needs more info" +# * number of issues with "needs investigation" +# * number of issues with label type/bug +# * number of open issues in current milestone +# +# https://github.com/grafana/grafana-github-actions/blob/main/metrics-collector/index.ts +# +name: Github issue metrics collection +on: + schedule: + - cron: "*/10 * * * *" + issues: + types: [opened, closed] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run metrics collector + uses: ./actions/metrics-collector + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + configPath: "metrics-collector" diff --git a/.github/workflows/pr-commands.yml b/.github/workflows/pr-commands.yml new file mode 100644 index 0000000..cd11598 --- /dev/null +++ b/.github/workflows/pr-commands.yml @@ -0,0 +1,25 @@ +name: PR automation +on: + pull_request_target: + types: + - opened + - synchronize + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run Commands + uses: ./actions/commands + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + configPath: pr-commands diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d9fd80b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,44 @@ +name: publish_docs + +on: + push: + branches: + - main + paths: + - 'docs/sources/**' + - 'packages/grafana-*/**' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync + - uses: actions/cache@v2.1.5 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: generate-packages-docs + uses: actions/setup-node@v2.1.5 + id: generate-docs + with: + node-version: '14' + - run: yarn install --pure-lockfile --no-progress + - run: ./scripts/ci-reference-docs-build.sh + - name: publish-to-git + uses: ./.github/actions/website-sync + id: publish + with: + repository: grafana/website + branch: master + host: github.com + github_pat: '${{ secrets.GH_BOT_ACCESS_TOKEN }}' + source_folder: docs/sources + target_folder: content/docs/grafana/next + allow_no_changes: 'true' + - shell: bash + run: | + test -n "${{ steps.publish.outputs.commit_hash }}" + test -n "${{ steps.publish.outputs.working_directory }}" diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..ecc7ff9 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,24 @@ +name: Update changelog +on: + workflow_dispatch: + inputs: + version: + required: true + description: Needs to match, exactly, the name of a milestone +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run update changelog + uses: ./actions/update-changelog + with: + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e5ef07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +node_modules +npm-debug.log +yarn-error.log +coverage/ +.aws-config.json +awsconfig +/.awcache +/dist +/public/build +/public/views/index.html +/public/views/error.html +/emails/dist +/reports +/e2e/tmp +.yarnrc +.yarn/ +vendor/ +/docs/menu.yaml +/requests + +# Enterprise emails +/emails/templates/enterprise_* +/public/emails/enterprise_* + +# Enterprise reporting fonts +/public/fonts/dejavu + +# Enterprise devenv +/devenv/docker/blocks/grafana-enterprise + +/tmp +tools/phantomjs/phantomjs +tools/phantomjs/phantomjs.exe +profile.out +coverage.txt + +docs/AWS_S3_BUCKET +docs/GIT_BRANCH +docs/GITCOMMIT +docs/changed-files + +# locally required config files +public/css/*.min.css + +# Editor junk +*.sublime-workspace +*.swp +.idea/ +*.iml +*.tmp +.DS_Store +.vscode/ +.vs/ +.eslintcache + +/data/* +/bin/* + +# devenv +/devenv/docker-compose.yaml +/devenv/.env + +conf/custom.ini +/conf/provisioning/**/custom.yaml +/conf/provisioning/**/dev.yaml +/conf/provisioning/access-control/ +/conf/ldap_dev.toml +/conf/ldap_freeipa.toml +profile.cov +/grafana +/local +.notouch +/Makefile.local +/pkg/cmd/grafana-cli/grafana-cli +/pkg/cmd/grafana-server/grafana-server +/pkg/cmd/grafana-server/debug +/pkg/extensions/* +!/pkg/extensions/main.go +/public/app/extensions +debug.test +/examples/*/dist +/packaging/**/*.rpm +/packaging/**/*.deb +/packaging/**/*.tar.gz + +# Ignore OSX indexing +.DS_Store + +/vendor/**/*.py +/vendor/**/*.xml +/vendor/**/*.yml +/vendor/**/*_test.go +/vendor/**/.editorconfig +*.orig + +/devenv/bulk-dashboards/*.json +/devenv/bulk_alerting_dashboards/*.json +/devenv/datasources_bulk.yaml +/devenv/bulk_alerting_dashboards/bulk_alerting_datasources.yaml + +/scripts/build/release_publisher/release_publisher +*.patch + +# Ignoring frontend packages specifics +/packages/**/dist +/packages/**/compiled +/packages/**/.rpt2_cache +/packages/**/tsdoc-metadata.json + +# Ignore go local build dependencies +/scripts/go/bin/** + +# Ignore compilation stats from `yarn stats` +compilation-stats.json + +# e2e tests +/packages/grafana-e2e/cypress/screenshots +/packages/grafana-e2e/cypress/videos +/packages/grafana-e2e/cypress/logs +/e2e/server.log +/e2e/**/screenshots +!/e2e/**/screenshots/expected/* +/e2e/**/videos/* + +# report dumping the whole system env +/report.*.json + +# auto generated frontend docs +/docs/sources/packages_api diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6d00a77 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +.git +.github +dist/ +pkg/ +node_modules +public/vendor/ +vendor/ +data/ +e2e/tmp +public/build/ +public/sass/*.generated.scss +devenv/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..c14684b --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('@grafana/toolkit/src/config/prettier.plugin.config.json'), +}; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..36df554 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2804 @@ + + + +# 7.5.6 (2021-05-11) + +### Features and enhancements + +* **Database**: Add isolation level configuration parameter for MySQL. [#33830](https://github.com/grafana/grafana/pull/33830), [@zserge](https://github.com/zserge) +* **InfluxDB**: Improve measurement-autocomplete behavior. [#33494](https://github.com/grafana/grafana/pull/33494), [@gabor](https://github.com/gabor) +* **Instrumentation**: Don't consider invalid email address a failed email. [#33671](https://github.com/grafana/grafana/pull/33671), [@bergquist](https://github.com/bergquist) + +### Bug fixes + +* **Loki**: fix label browser crashing when + typed. [#33900](https://github.com/grafana/grafana/pull/33900), [@zoltanbedi](https://github.com/zoltanbedi) +* **Prometheus**: Sanitize PromLink button. [#33874](https://github.com/grafana/grafana/pull/33874), [@ivanahuckova](https://github.com/ivanahuckova) + + + + + +# 7.5.5 (2021-04-28) + +### Features and enhancements + +* **Explore:** Load default data source in Explore when the provided source does not exist. [#32992](https://github.com/grafana/grafana/pull/32992), [@ifrost](https://github.com/ifrost) +* **Instrumentation:** Add success rate metrics for email notifications. [#33359](https://github.com/grafana/grafana/pull/33359), [@bergquist](https://github.com/bergquist) + +### Bug fixes + +* **Alerting:** Remove field limitation from Slack notifications. [#33113](https://github.com/grafana/grafana/pull/33113), [@dsotirakis](https://github.com/dsotirakis) +* **Auth:** Do not clear auth token cookie when token lookup fails. [#32999](https://github.com/grafana/grafana/pull/32999), [@marefr](https://github.com/marefr) +* **Bug:** Add git command to Dockerfile.ubuntu file. [#33247](https://github.com/grafana/grafana/pull/33247), [@dsotirakis](https://github.com/dsotirakis) +* **Explore:** Adjust time to the selected timezone. [#33315](https://github.com/grafana/grafana/pull/33315), [@ifrost](https://github.com/ifrost) +* **GraphNG:** Fix exemplars window position. [#33427](https://github.com/grafana/grafana/pull/33427), [@zoltanbedi](https://github.com/zoltanbedi) +* **Loki:** Pass Skip TLS Verify setting to alert queries. [#33025](https://github.com/grafana/grafana/pull/33025), [@ivanahuckova](https://github.com/ivanahuckova) +* **Postgres:** Fix time group macro when TimescaleDB is enabled and interval is less than a second. [#33153](https://github.com/grafana/grafana/pull/33153), [@marefr](https://github.com/marefr) + + + + + +# 7.5.4 (2021-04-14) + +### Features and enhancements + +* **AzureMonitor**: Add support for Microsoft.AppConfiguration/configurationStores namespace. [#32123](https://github.com/grafana/grafana/pull/32123), [@deesejohn](https://github.com/deesejohn) +* **TablePanel**: Make sorting case-insensitive. [#32435](https://github.com/grafana/grafana/pull/32435), [@kaydelaney](https://github.com/kaydelaney) + +### Bug fixes + +* **AzureMonitor**: Add support for Virtual WAN namespaces. [#32935](https://github.com/grafana/grafana/pull/32935), [@joshhunt](https://github.com/joshhunt) +* **Bugfix**: Add proper padding when scrolling is added to bar gauge. [#32411](https://github.com/grafana/grafana/pull/32411), [@mckn](https://github.com/mckn) +* **Datasource**: Prevent default data source named "default" from causing infinite loop. [#32949](https://github.com/grafana/grafana/pull/32949), [@jackw](https://github.com/jackw) +* **Prometheus**: Allow query_exemplars endpoint in data source proxy. [#32802](https://github.com/grafana/grafana/pull/32802), [@zoltanbedi](https://github.com/zoltanbedi) +* **Table**: Fix table data links so they refer to correct row after sorting. [#32571](https://github.com/grafana/grafana/pull/32571), [@torkelo](https://github.com/torkelo) + + + + + +# 7.5.3 (2021-04-07) + +### Features and enhancements + +* **Dashboard**: Do not include default datasource when externally exporting dashboard with row. [#32494](https://github.com/grafana/grafana/pull/32494), [@kaydelaney](https://github.com/kaydelaney) +* **Loki**: Remove empty annotations tags. [#32359](https://github.com/grafana/grafana/pull/32359), [@conorevans](https://github.com/conorevans) + +### Bug fixes + +* **AdHocVariable**: Add default data source to picker. [#32470](https://github.com/grafana/grafana/pull/32470), [@hugohaggmark](https://github.com/hugohaggmark) +* **Configuration**: Prevent browser hanging / crashing with large number of org users. [#32546](https://github.com/grafana/grafana/pull/32546), [@jackw](https://github.com/jackw) +* **Elasticsearch**: Fix bucket script variable duplication in UI. [#32705](https://github.com/grafana/grafana/pull/32705), [@Elfo404](https://github.com/Elfo404) +* **Explore**: Fix bug where navigating to explore would result in wrong query and datasource to be shown. [#32558](https://github.com/grafana/grafana/pull/32558), [@aocenas](https://github.com/aocenas) +* **FolderPicker**: Prevent dropdown menu from disappearing off screen. [#32603](https://github.com/grafana/grafana/pull/32603), [@jackw](https://github.com/jackw) +* **SingleStat**: Fix issue with panel links. [#32721](https://github.com/grafana/grafana/pull/32721), [@gjulianm](https://github.com/gjulianm) +* **Variables**: Confirm selection before opening new picker. [#32586](https://github.com/grafana/grafana/pull/32586), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Confirm selection before opening new picker. [#32503](https://github.com/grafana/grafana/pull/32503), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fix unsupported data format error for null values. [#32480](https://github.com/grafana/grafana/pull/32480), [@hugohaggmark](https://github.com/hugohaggmark) + + + + + +# 7.5.2 (2021-03-30) + +### Features and enhancements + +* **Explore**: Set Explore's GraphNG to use default value for connected null values setting. [#32471](https://github.com/grafana/grafana/pull/32471), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug fixes + +* **DashboardDataSource**: Fix query not being executed after selecting source panel. [#32383](https://github.com/grafana/grafana/pull/32383), [@torkelo](https://github.com/torkelo) +* **Graph**: Fix setting right y-axis when standard option unit is configured. [#32426](https://github.com/grafana/grafana/pull/32426), [@torkelo](https://github.com/torkelo) +* **Table**: Fix links for image cells. [#32370](https://github.com/grafana/grafana/pull/32370), [@kaydelaney](https://github.com/kaydelaney) +* **Variables**: Fix data source variable when default data source is selected. [#32384](https://github.com/grafana/grafana/pull/32384), [@torkelo](https://github.com/torkelo) +* **Variables**: Fix manually entering non-matching custom value in variable input/picker error. [#32390](https://github.com/grafana/grafana/pull/32390), [@torkelo](https://github.com/torkelo) + + + + + +# 7.5.1 (2021-03-26) + +### Bug fixes + +* **MSSQL**: Fix panic not implemented by upgrading go-mssqldb dependency. [#32347](https://github.com/grafana/grafana/pull/32347), [@aknuds1](https://github.com/aknuds1) + + + + + +# 7.5.0 (2021-03-25) + +### Features and enhancements + +* **Alerting**: Add ability to include aliases with hyphen in InfluxDB. [#32262](https://github.com/grafana/grafana/pull/32262), [@grafanabot](https://github.com/grafanabot) +* **CloudWatch**: Use latest version of aws sdk. [#32217](https://github.com/grafana/grafana/pull/32217), [@sunker](https://github.com/sunker) + +### Bug fixes + +* **Alerting**: Add ability to include aliases with hyphen in InfluxDB. [#32224](https://github.com/grafana/grafana/pull/32224), [@dsotirakis](https://github.com/dsotirakis) +* **DashboardSettings**: Fixes issue with tags list not updating changes are made. [#32241](https://github.com/grafana/grafana/pull/32241), [@huynhsamha](https://github.com/huynhsamha) +* **DashboardSettings**: Fixes issue with tags list not updating changes are made. [#32189](https://github.com/grafana/grafana/pull/32189), [@huynhsamha](https://github.com/huynhsamha) +* **Loki**: Fix text search in Label browser. [#32293](https://github.com/grafana/grafana/pull/32293), [@ivanahuckova](https://github.com/ivanahuckova) + + + + + +# 7.5.0-beta2 (2021-03-19) + +### Features and enhancements + +* **CloudWatch**: Add support for EC2 IAM role. [#31804](https://github.com/grafana/grafana/pull/31804), [@sunker](https://github.com/sunker) +* **CloudWatch**: Consume the grafana/aws-sdk. [#31807](https://github.com/grafana/grafana/pull/31807), [@sunker](https://github.com/sunker) +* **CloudWatch**: Restrict auth provider and assume role usage according to Grafana configuration. [#31805](https://github.com/grafana/grafana/pull/31805), [@sunker](https://github.com/sunker) +* **Cloudwatch**: ListMetrics API page limit. [#31788](https://github.com/grafana/grafana/pull/31788), [@sunker](https://github.com/sunker) +* **Cloudwatch**: Use shared library for aws auth. [#29550](https://github.com/grafana/grafana/pull/29550), [@ryantxu](https://github.com/ryantxu) +* **DataLinks**: Bring back single click links for Stat, Gauge and BarGauge panel. [#31692](https://github.com/grafana/grafana/pull/31692), [@dprokop](https://github.com/dprokop) +* **Docker**: Support pre-installed plugins from other sources in custom Dockerfiles. [#31234](https://github.com/grafana/grafana/pull/31234), [@sgnsys3](https://github.com/sgnsys3) +* **Elasticseach**: Add support for histogram fields. [#29079](https://github.com/grafana/grafana/pull/29079), [@simianhacker](https://github.com/simianhacker) +* **Exemplars**: Always query exemplars. [#31673](https://github.com/grafana/grafana/pull/31673), [@zoltanbedi](https://github.com/zoltanbedi) +* **Explore**: Support full inspect drawer. [#32005](https://github.com/grafana/grafana/pull/32005), [@ivanahuckova](https://github.com/ivanahuckova) +* **HttpServer**: Make read timeout configurable but disabled by default. [#31575](https://github.com/grafana/grafana/pull/31575), [@bergquist](https://github.com/bergquist) +* **SQLStore**: Close session in withDbSession. [#31775](https://github.com/grafana/grafana/pull/31775), [@aknuds1](https://github.com/aknuds1) +* **Templating**: Use dashboard timerange when variables are set to refresh 'On Dashboard Load'. [#31721](https://github.com/grafana/grafana/pull/31721), [@Elfo404](https://github.com/Elfo404) +* **Tempo**: Convert to backend data source. [#31618](https://github.com/grafana/grafana/pull/31618), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug fixes + +* **Admin**: Keeps expired api keys visible in table after delete. [#31636](https://github.com/grafana/grafana/pull/31636), [@hugohaggmark](https://github.com/hugohaggmark) +* **Data proxy**: Fix encoded characters in URL path should be proxied as encoded. [#30597](https://github.com/grafana/grafana/pull/30597), [@marefr](https://github.com/marefr) +* **Explore/Logs**: Fix escaping in ANSI logs. [#31731](https://github.com/grafana/grafana/pull/31731), [@ivanahuckova](https://github.com/ivanahuckova) +* **GraphNG**: Fix tooltip series color for multi data frame scenario. [#32098](https://github.com/grafana/grafana/pull/32098), [@dprokop](https://github.com/dprokop) +* **GraphNG**: Make sure data set and config are in sync when initializing and re-initializing uPlot. [#32106](https://github.com/grafana/grafana/pull/32106), [@dprokop](https://github.com/dprokop) +* **Loki**: Fix autocomplete when re-editing Loki label values. [#31828](https://github.com/grafana/grafana/pull/31828), [@ivanahuckova](https://github.com/ivanahuckova) +* **MixedDataSource**: Name is updated when data source variables change. [#32090](https://github.com/grafana/grafana/pull/32090), [@hugohaggmark](https://github.com/hugohaggmark) +* **PanelInspect**: Interpolates variables in CSV file name. [#31936](https://github.com/grafana/grafana/pull/31936), [@hugohaggmark](https://github.com/hugohaggmark) +* **ReduceTransform**: Include series with numeric string names. [#31763](https://github.com/grafana/grafana/pull/31763), [@hugohaggmark](https://github.com/hugohaggmark) +* **Snapshots**: Fix usage of sign in link from the snapshot page. [#31986](https://github.com/grafana/grafana/pull/31986), [@marefr](https://github.com/marefr) +* **TimePicker**: Fixes hidden time picker shown in kiosk TV mode. [#32062](https://github.com/grafana/grafana/pull/32062), [@torkelo](https://github.com/torkelo) +* **ValueMappings**: Fixes value 0 not being mapped. [#31924](https://github.com/grafana/grafana/pull/31924), [@Willena](https://github.com/Willena) +* **Variables**: Fixes filtering in picker with null items. [#31979](https://github.com/grafana/grafana/pull/31979), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Improves inspection performance and unknown filtering. [#31811](https://github.com/grafana/grafana/pull/31811), [@hugohaggmark](https://github.com/hugohaggmark) + +### Plugin development fixes & changes + +* **Auth**: Allow soft token revocation. [#31601](https://github.com/grafana/grafana/pull/31601), [@joanlopez](https://github.com/joanlopez) + + + + + +# 7.5.0-beta1 (2021-03-04) + +### Features and enhancements + +* **Alerting**: Customise OK notification priorities for Pushover notifier. [#30169](https://github.com/grafana/grafana/pull/30169), [@acaire](https://github.com/acaire) +* **Alerting**: Improve default message for SensuGo notifier. [#31428](https://github.com/grafana/grafana/pull/31428), [@M4teo](https://github.com/M4teo) +* **Alerting**: PagerDuty: adding current state to the payload. [#29270](https://github.com/grafana/grafana/pull/29270), [@Eraac](https://github.com/Eraac) +* **AzureMonitor**: Add deprecation message for App Insights/Insights Analytics. [#30633](https://github.com/grafana/grafana/pull/30633), [@joshhunt](https://github.com/joshhunt) +* **CloudMonitoring**: Allow free text input for GCP project on dashboard variable query. [#28048](https://github.com/grafana/grafana/issues/28048) +* **CloudMonitoring**: Increase service api page size. [#30892](https://github.com/grafana/grafana/pull/30892), [@sunker](https://github.com/sunker) +* **CloudMonitoring**: Show service and SLO display name in SLO Query editor. [#30900](https://github.com/grafana/grafana/pull/30900), [@sunker](https://github.com/sunker) +* **CloudWatch**: Add AWS Ground Station metrics and dimensions. [#31362](https://github.com/grafana/grafana/pull/31362), [@ilyastoli](https://github.com/ilyastoli) +* **CloudWatch**: Add AWS Network Firewall metrics and dimensions. [#31498](https://github.com/grafana/grafana/pull/31498), [@ilyastoli](https://github.com/ilyastoli) +* **CloudWatch**: Add AWS Timestream Metrics and Dimensions. [#31624](https://github.com/grafana/grafana/pull/31624), [@ilyastoli](https://github.com/ilyastoli) +* **CloudWatch**: Add RDS Proxy metrics. [#31595](https://github.com/grafana/grafana/pull/31595), [@sunker](https://github.com/sunker) +* **CloudWatch**: Add eu-south-1 Cloudwatch region. [#31198](https://github.com/grafana/grafana/pull/31198), [@rubycut](https://github.com/rubycut) +* **CloudWatch**: Make it possible to specify custom api endpoint. [#31402](https://github.com/grafana/grafana/pull/31402), [@sunker](https://github.com/sunker) +* **Cloudwatch**: Add AWS/DDoSProtection metrics and dimensions. [#31297](https://github.com/grafana/grafana/pull/31297), [@relvira](https://github.com/relvira) +* **Dashboard**: Remove template variables option from ShareModal. [#30395](https://github.com/grafana/grafana/pull/30395), [@oscarkilhed](https://github.com/oscarkilhed) +* **Docs**: Define TLS/SSL terminology. [#30533](https://github.com/grafana/grafana/pull/30533), [@aknuds1](https://github.com/aknuds1) +* **Elasticsearch**: Add word highlighting to search results. [#30293](https://github.com/grafana/grafana/pull/30293), [@simianhacker](https://github.com/simianhacker) +* **Folders**: Editors should be able to edit name and delete folders. [#31242](https://github.com/grafana/grafana/pull/31242), [@torkelo](https://github.com/torkelo) +* **Graphite/SSE**: update graphite to work with server side expressions. [#31455](https://github.com/grafana/grafana/pull/31455), [@kylebrandt](https://github.com/kylebrandt) +* **InfluxDB**: Improve maxDataPoints error-message in Flux-mode, raise limits. [#31259](https://github.com/grafana/grafana/pull/31259), [@gabor](https://github.com/gabor) +* **InfluxDB**: In flux query editor, do not run query when disabled. [#31324](https://github.com/grafana/grafana/pull/31324), [@gabor](https://github.com/gabor) +* **LogsPanel**: Add deduplication option for logs. [#31019](https://github.com/grafana/grafana/pull/31019), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Add line limit for annotations. [#31183](https://github.com/grafana/grafana/pull/31183), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Add support for alerting. [#31424](https://github.com/grafana/grafana/pull/31424), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Label browser. [#30351](https://github.com/grafana/grafana/pull/30351), [@davkal](https://github.com/davkal) +* **PieChart**: Add color changing options to pie chart. [#31588](https://github.com/grafana/grafana/pull/31588), [@oscarkilhed](https://github.com/oscarkilhed) +* **PostgreSQL**: Allow providing TLS/SSL certificates as text in addition to file paths. [#30353](https://github.com/grafana/grafana/pull/30353), [@ying-jeanne](https://github.com/ying-jeanne) +* **Postgres**: SSL certification. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne) +* **Profile**: Prevent OAuth users from changing user details or password. [#27886](https://github.com/grafana/grafana/pull/27886), [@dupondje](https://github.com/dupondje) +* **Prometheus**: Change default httpMethod for new instances to POST. [#31292](https://github.com/grafana/grafana/pull/31292), [@ivanahuckova](https://github.com/ivanahuckova) +* **Prometheus**: Min step defaults to seconds when no unit is set. [#30966](https://github.com/grafana/grafana/pull/30966), [@nutmos](https://github.com/nutmos) +* **Stats**: Exclude folders from total dashboard count. [#31320](https://github.com/grafana/grafana/pull/31320), [@bergquist](https://github.com/bergquist) +* **Tracing**: Specify type of data frame that is expected for TraceView. [#31465](https://github.com/grafana/grafana/pull/31465), [@aocenas](https://github.com/aocenas) +* **Transformers**: Add search to transform selection. [#30854](https://github.com/grafana/grafana/pull/30854), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +* **Alerting**: Ensure Discord notification is sent when metric name is absent. [#31257](https://github.com/grafana/grafana/pull/31257), [@LeviHarrison](https://github.com/LeviHarrison) +* **Alerting**: Fix case when Alertmanager notifier fails if a URL is not working. [#31079](https://github.com/grafana/grafana/pull/31079), [@kurokochin](https://github.com/kurokochin) +* **CloudMonitoring**: Prevent resource type variable function from crashing. [#30901](https://github.com/grafana/grafana/pull/30901), [@sunker](https://github.com/sunker) +* **Color**: Fix issue where colors are reset to gray when switching panels. [#31611](https://github.com/grafana/grafana/pull/31611), [@torkelo](https://github.com/torkelo) +* **Explore**: Show ANSI colored logs in logs context. [#31510](https://github.com/grafana/grafana/pull/31510), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: keep enabled/disabled state in angular based QueryEditors correctly. [#31558](https://github.com/grafana/grafana/pull/31558), [@gabor](https://github.com/gabor) +* **Graph**: Fix tooltip not being displayed when close to edge of viewport. [#31493](https://github.com/grafana/grafana/pull/31493), [@msober](https://github.com/msober) +* **Heatmap**: Fix missing value in legend. [#31430](https://github.com/grafana/grafana/pull/31430), [@kurokochin](https://github.com/kurokochin) +* **InfluxDB**: Handle columns named "table". [#30985](https://github.com/grafana/grafana/pull/30985), [@gabor](https://github.com/gabor) +* **Prometheus**: Use configured HTTP method for /series and /labels endpoints. [#31401](https://github.com/grafana/grafana/pull/31401), [@ivanahuckova](https://github.com/ivanahuckova) +* **RefreshPicker**: Make valid intervals in url visible in RefreshPicker. [#30474](https://github.com/grafana/grafana/pull/30474), [@hugohaggmark](https://github.com/hugohaggmark) +* **TimeSeriesPanel**: Fix overlapping time axis ticks. [#31332](https://github.com/grafana/grafana/pull/31332), [@torkelo](https://github.com/torkelo) +* **TraceViewer**: Fix show log marker in spanbar. [#30742](https://github.com/grafana/grafana/pull/30742), [@zoltanbedi](https://github.com/zoltanbedi) + +### Plugin development fixes & changes + +* **Plugins**: Add autoEnabled plugin JSON field to auto enable App plugins and add configuration link to menu by default. [#31354](https://github.com/grafana/grafana/pull/31354), [@torkelo](https://github.com/torkelo) +* **Pagination**: Improve pagination for large number of pages. [#30151](https://github.com/grafana/grafana/pull/30151), [@nathanrodman](https://github.com/nathanrodman) + + + + + +# 7.4.5 (2021-03-18) + +### Bug fixes + +* **Security**: Fix API permissions issues related to team-sync CVE-2021-28146, CVE-2021-28147. (Enterprise) +* **Security**: Usage insights requires signed in users CVE-2021-28148. (Enterprise) +* **Security**: Do not allow editors to incorrectly bypass permissions on the default data source. CVE-2021-27962. (Enterprise) + + + + + +# 7.4.3 (2021-02-24) + +### Bug fixes + +* **AdHocVariables**: Fixes crash when values are stored as numbers. [#31382](https://github.com/grafana/grafana/pull/31382), [@hugohaggmark](https://github.com/hugohaggmark) +* **DashboardLinks**: Fix an issue where the dashboard links were causing a full page reload. [#31334](https://github.com/grafana/grafana/pull/31334), [@torkelo](https://github.com/torkelo) +* **Elasticsearch**: Fix query initialization logic & query transformation from Prometheus/Loki. [#31322](https://github.com/grafana/grafana/pull/31322), [@Elfo404](https://github.com/Elfo404) +* **QueryEditor**: Fix disabling queries in dashboards. [#31336](https://github.com/grafana/grafana/pull/31336), [@gabor](https://github.com/gabor) +* **Streaming**: Fix an issue with the time series panel and streaming data source when scrolling back from being out of view. [#31431](https://github.com/grafana/grafana/pull/31431), [@torkelo](https://github.com/torkelo) +* **Table**: Fix an issue regarding the fixed min and auto max values in bar gauge cell. [#31316](https://github.com/grafana/grafana/pull/31316), [@torkelo](https://github.com/torkelo) + + + + + +# 7.4.2 (2021-02-17) + +### Features and enhancements + +* **Explore**: Do not show non queryable data sources in data source picker. [#31144](https://github.com/grafana/grafana/pull/31144), [@torkelo](https://github.com/torkelo) +* **Security**: Do not allow an anonymous user to create snapshots. CVE-2021-27358. [#31263](https://github.com/grafana/grafana/pull/31263), [@marefr](https://github.com/marefr) + +### Bug fixes + +* **CloudWatch**: Ensure empty query row errors are not passed to the panel. [#31172](https://github.com/grafana/grafana/pull/31172), [@sunker](https://github.com/sunker) +* **DashboardLinks**: Fix the links that always cause a full page to reload. [#31178](https://github.com/grafana/grafana/pull/31178), [@torkelo](https://github.com/torkelo) +* **DashboardListPanel**: Fix issue with folder picker always showing All and using old form styles. [#31160](https://github.com/grafana/grafana/pull/31160), [@torkelo](https://github.com/torkelo) +* **IPv6**: Support host address configured with enclosing square brackets. [#31226](https://github.com/grafana/grafana/pull/31226), [@aknuds1](https://github.com/aknuds1) +* **Permissions**: Fix team and role permissions on folders/dashboards not displayed for non Grafana Admin users. [#31132](https://github.com/grafana/grafana/pull/31132), [@AgnesToulet](https://github.com/AgnesToulet) +* **Postgres**: Fix timeGroup macro converts long intervals to invalid numbers when TimescaleDB is enabled. [#31179](https://github.com/grafana/grafana/pull/31179), [@kurokochin](https://github.com/kurokochin) +* **Prometheus**: Fix enabling of disabled queries when editing in dashboard. [#31055](https://github.com/grafana/grafana/pull/31055), [@ivanahuckova](https://github.com/ivanahuckova) +* **QueryEditors**: Fix an issue that happens after moving queries then editing would update other queries. [#31193](https://github.com/grafana/grafana/pull/31193), [@torkelo](https://github.com/torkelo) +* **SqlDataSources**: Fix the Show Generated SQL button in query editors. [#31236](https://github.com/grafana/grafana/pull/31236), [@torkelo](https://github.com/torkelo) +* **StatPanels**: Fix an issue where the palette color scheme is not cleared when loading panel. [#31126](https://github.com/grafana/grafana/pull/31126), [@torkelo](https://github.com/torkelo) +* **Variables**: Add the default option back for the data source variable. [#31208](https://github.com/grafana/grafana/pull/31208), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fix missing empty elements from regex filters. [#31156](https://github.com/grafana/grafana/pull/31156), [@hugohaggmark](https://github.com/hugohaggmark) + + + + + + + + + + +# 7.4.1 (2021-02-11) + +### Features and enhancements + +* **Influx**: Make max series limit configurable and show the limiting message if applied. [#31025](https://github.com/grafana/grafana/pull/31025), [@aocenas](https://github.com/aocenas) +* **Make value mappings correctly interpret numeric-like strings**. [#30893](https://github.com/grafana/grafana/pull/30893), [@dprokop](https://github.com/dprokop) +* **Variables**: Adds queryparam formatting option. [#30858](https://github.com/grafana/grafana/pull/30858), [@hugohaggmark](https://github.com/hugohaggmark) + +### Bug fixes + +* **Alerting**: Fixes so notification channels are properly deleted. [#31040](https://github.com/grafana/grafana/pull/31040), [@hugohaggmark](https://github.com/hugohaggmark) +* **BarGauge**: Improvements to value sizing and table inner width calculations. [#30990](https://github.com/grafana/grafana/pull/30990), [@torkelo](https://github.com/torkelo) +* **DashboardLinks**: Fixes crash when link has no title. [#31008](https://github.com/grafana/grafana/pull/31008), [@hugohaggmark](https://github.com/hugohaggmark) +* **Elasticsearch**: Fix alias field value not being shown in query editor. [#30992](https://github.com/grafana/grafana/pull/30992), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Fix log row context errors. [#31088](https://github.com/grafana/grafana/pull/31088), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Show Size setting for raw_data metric. [#30980](https://github.com/grafana/grafana/pull/30980), [@Elfo404](https://github.com/Elfo404) +* **Graph**: Fixes so graph is shown for non numeric time values. [#30972](https://github.com/grafana/grafana/pull/30972), [@hugohaggmark](https://github.com/hugohaggmark) +* **Logging**: Ignore 'file already closed' error when closing file. [#31119](https://github.com/grafana/grafana/pull/31119), [@aknuds1](https://github.com/aknuds1) +* **Plugins**: Fix plugin signature validation for manifest v2 on Windows. [#31045](https://github.com/grafana/grafana/pull/31045), [@wbrowne](https://github.com/wbrowne) +* **TextPanel**: Fixes so panel title is updated when variables change. [#30884](https://github.com/grafana/grafana/pull/30884), [@hugohaggmark](https://github.com/hugohaggmark) +* **Transforms**: Fixes Outer join issue with duplicate field names not getting the same unique field names as before. [#31121](https://github.com/grafana/grafana/pull/31121), [@torkelo](https://github.com/torkelo) + + + + + +# 7.4.0 (2021-02-04) + +### Features and enhancements + +* **CDN**: Adds support for serving assets over a CDN. [#30691](https://github.com/grafana/grafana/pull/30691), [@torkelo](https://github.com/torkelo) +* **DashboardLinks**: Support variable expression in to tooltip - Issue #30409. [#30569](https://github.com/grafana/grafana/pull/30569), [@huynhsamha](https://github.com/huynhsamha) +* **Explore**: Set Explore's GraphNG to be connected. [#30707](https://github.com/grafana/grafana/pull/30707), [@ivanahuckova](https://github.com/ivanahuckova) +* **InfluxDB**: Add http configuration when selecting InfluxDB v2 flavor. [#30827](https://github.com/grafana/grafana/pull/30827), [@aocenas](https://github.com/aocenas) +* **InfluxDB**: Show all datapoints for dynamically windowed flux query. [#30688](https://github.com/grafana/grafana/pull/30688), [@davkal](https://github.com/davkal) +* **Loki**: Improve live tailing errors. [#30517](https://github.com/grafana/grafana/pull/30517), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug fixes + +* **Admin**: Fixes so form values are filled in from backend. [#30544](https://github.com/grafana/grafana/pull/30544), [@hugohaggmark](https://github.com/hugohaggmark) +* **Admin**: Fixes so whole org drop down is visible when adding users to org. [#30481](https://github.com/grafana/grafana/pull/30481), [@hugohaggmark](https://github.com/hugohaggmark) +* **Alerting**: Hides threshold handle for percentual thresholds. [#30431](https://github.com/grafana/grafana/pull/30431), [@hugohaggmark](https://github.com/hugohaggmark) +* **CloudWatch**: Prevent field config from being overwritten. [#30437](https://github.com/grafana/grafana/pull/30437), [@sunker](https://github.com/sunker) +* **Decimals**: Big Improvements to auto decimals and fixes to auto decimals bug found in 7.4-beta1. [#30519](https://github.com/grafana/grafana/pull/30519), [@torkelo](https://github.com/torkelo) +* **Explore**: Fix jumpy live tailing. [#30650](https://github.com/grafana/grafana/pull/30650), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: Fix loading visualisation on the top of the new time series panel. [#30553](https://github.com/grafana/grafana/pull/30553), [@ivanahuckova](https://github.com/ivanahuckova) +* **Footer**: Fixes layout issue in footer. [#30443](https://github.com/grafana/grafana/pull/30443), [@torkelo](https://github.com/torkelo) +* **Graph**: Fixes so only users with correct permissions can add annotations. [#30419](https://github.com/grafana/grafana/pull/30419), [@hugohaggmark](https://github.com/hugohaggmark) +* **Mobile**: Fixes issue scrolling on mobile in chrome. [#30746](https://github.com/grafana/grafana/pull/30746), [@torkelo](https://github.com/torkelo) +* **PanelEdit**: Trigger refresh when changing data source. [#30744](https://github.com/grafana/grafana/pull/30744), [@torkelo](https://github.com/torkelo) +* **Panels**: Fixes so panels are refreshed when scrolling past them fast. [#30784](https://github.com/grafana/grafana/pull/30784), [@hugohaggmark](https://github.com/hugohaggmark) +* **Prometheus**: Fix show query instead of Value if no __name__ and metric. [#30511](https://github.com/grafana/grafana/pull/30511), [@zoltanbedi](https://github.com/zoltanbedi) +* **TimeSeriesPanel**: Fixes default value for Gradient mode. [#30484](https://github.com/grafana/grafana/pull/30484), [@torkelo](https://github.com/torkelo) +* **Variables**: Clears drop down state when leaving dashboard. [#30810](https://github.com/grafana/grafana/pull/30810), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes display value when using capture groups in regex. [#30636](https://github.com/grafana/grafana/pull/30636), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes so queries work for numbers values too. [#30602](https://github.com/grafana/grafana/pull/30602), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes so text format will show All instead of custom all value. [#30730](https://github.com/grafana/grafana/pull/30730), [@hugohaggmark](https://github.com/hugohaggmark) + +### Plugin development fixes & changes + +* **Plugins**: Fix failing plugin builds because of wrong internal import. [#30439](https://github.com/grafana/grafana/pull/30439), [@aocenas](https://github.com/aocenas) + + + + + +# 7.4.0-beta1 (2021-01-20) + +### Features and enhancements + +* **API**: Add ID to snapshot API responses. [#29600](https://github.com/grafana/grafana/pull/29600), [@AgnesToulet](https://github.com/AgnesToulet) +* **AlertListPanel**: Add options to sort by Time(asc) and Time(desc). [#29764](https://github.com/grafana/grafana/pull/29764), [@dboslee](https://github.com/dboslee) +* **AlertListPanel**: Changed alert url to to go the panel view instead of panel edit. [#29060](https://github.com/grafana/grafana/pull/29060), [@zakiharis](https://github.com/zakiharis) +* **Alerting**: Add support for Sensu Go notification channel. [#28012](https://github.com/grafana/grafana/pull/28012), [@nixwiz](https://github.com/nixwiz) +* **Alerting**: Add support for alert notification query label interpolation. [#29908](https://github.com/grafana/grafana/pull/29908), [@wbrowne](https://github.com/wbrowne) +* **Annotations**: Remove annotation_tag entries as part of annotations cleanup. [#29534](https://github.com/grafana/grafana/pull/29534), [@dafydd-t](https://github.com/dafydd-t) +* **Azure Monitor**: Add Microsoft.Network/natGateways. [#29479](https://github.com/grafana/grafana/pull/29479), [@JoeyLemur](https://github.com/JoeyLemur) +* **Backend plugins**: Support Forward OAuth Identity for backend data source plugins. [#27055](https://github.com/grafana/grafana/pull/27055), [@billoley](https://github.com/billoley) +* **Cloud Monitoring**: MQL support. [#26551](https://github.com/grafana/grafana/pull/26551), [@mtanda](https://github.com/mtanda) +* **CloudWatch**: Add 'EventBusName' dimension to CloudWatch 'AWS/Events' namespace. [#28402](https://github.com/grafana/grafana/pull/28402), [@tomdaly](https://github.com/tomdaly) +* **CloudWatch**: Add support for AWS DirectConnect ConnectionErrorCount metric. [#29583](https://github.com/grafana/grafana/pull/29583), [@haeringer](https://github.com/haeringer) +* **CloudWatch**: Add support for AWS/ClientVPN metrics and dimensions. [#29055](https://github.com/grafana/grafana/pull/29055), [@marefr](https://github.com/marefr) +* **CloudWatch**: Added HTTP API Gateway specific metrics and dimensions. [#28780](https://github.com/grafana/grafana/pull/28780), [@karlatkinson](https://github.com/karlatkinson) +* **Configuration**: Add an option to hide certain users in the UI. [#28942](https://github.com/grafana/grafana/pull/28942), [@AgnesToulet](https://github.com/AgnesToulet) +* **Currency**: Adds Indonesian IDR currency. [#28363](https://github.com/grafana/grafana/pull/28363), [@hiddenrebel](https://github.com/hiddenrebel) +* **Dashboards**: Delete related data (permissions, stars, tags, versions, annotations) when deleting a dashboard or a folder. [#28826](https://github.com/grafana/grafana/pull/28826), [@AgnesToulet](https://github.com/AgnesToulet) +* **Dependencies**: Update angularjs to 1.8.2. [#28736](https://github.com/grafana/grafana/pull/28736), [@torkelo](https://github.com/torkelo) +* **Docker**: Use root group in the custom Dockerfile. [#28639](https://github.com/grafana/grafana/pull/28639), [@chugunov](https://github.com/chugunov) +* **Elasticsearch**: Add Moving Function Pipeline Aggregation. [#28131](https://github.com/grafana/grafana/pull/28131), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: Add Support for Serial Differencing Pipeline Aggregation. [#28618](https://github.com/grafana/grafana/pull/28618), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: Deprecate browser access mode. [#29649](https://github.com/grafana/grafana/pull/29649), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Interpolate variables in Filters Bucket Aggregation. [#28969](https://github.com/grafana/grafana/pull/28969), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Support extended stats and percentiles in terms order by. [#28910](https://github.com/grafana/grafana/pull/28910), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: View in context feature for logs. [#28764](https://github.com/grafana/grafana/pull/28764), [@simianhacker](https://github.com/simianhacker) +* **Explore/Logs**: Alphabetically sort unique labels, labels and parsed fields. [#29030](https://github.com/grafana/grafana/pull/29030), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore/Logs**: Update Parsed fields to Detected fields. [#28881](https://github.com/grafana/grafana/pull/28881), [@ivanahuckova](https://github.com/ivanahuckova) +* **Field overrides**: Added matcher to match all fields returned by a specific query. [#28872](https://github.com/grafana/grafana/pull/28872), [@mckn](https://github.com/mckn) +* **Graph**: Add support for spline interpolation (smoothing) added in new time series panel. [#4303](https://github.com/grafana/grafana/issues/4303) +* **Instrumentation**: Add histograms for database queries. [#29662](https://github.com/grafana/grafana/pull/29662), [@dafydd-t](https://github.com/dafydd-t) +* **Jaeger**: Remove browser access mode. [#30349](https://github.com/grafana/grafana/pull/30349), [@zoltanbedi](https://github.com/zoltanbedi) +* **LogsPanel**: Don't show scroll bars when not needed. [#28972](https://github.com/grafana/grafana/pull/28972), [@aocenas](https://github.com/aocenas) +* **Loki**: Add query type and line limit to query editor in dashboard. [#29356](https://github.com/grafana/grafana/pull/29356), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Add query type selector to query editor in Explore. [#28817](https://github.com/grafana/grafana/pull/28817), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Retry web socket connection when connection is closed abnormally. [#29438](https://github.com/grafana/grafana/pull/29438), [@ivanahuckova](https://github.com/ivanahuckova) +* **MS SQL**: Integrated security. [#30369](https://github.com/grafana/grafana/pull/30369), [@daniellee](https://github.com/daniellee) +* **Middleware**: Add CSP support. [#29740](https://github.com/grafana/grafana/pull/29740), [@aknuds1](https://github.com/aknuds1) +* **OAuth**: Configurable user name attribute. [#28286](https://github.com/grafana/grafana/pull/28286), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **PanelEditor**: Render panel field config categories as separate option group sections. [#30301](https://github.com/grafana/grafana/pull/30301), [@dprokop](https://github.com/dprokop) +* **Postgres**: SSL certification. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne) +* **Prometheus**: Add support for Exemplars. [#28057](https://github.com/grafana/grafana/pull/28057), [@zoltanbedi](https://github.com/zoltanbedi) +* **Prometheus**: Improve autocomplete performance and remove disabling of dynamic label lookup. [#30199](https://github.com/grafana/grafana/pull/30199), [@ivanahuckova](https://github.com/ivanahuckova) +* **Prometheus**: Update default query type option to "Both" in Explore query editor. [#28935](https://github.com/grafana/grafana/pull/28935), [@ivanahuckova](https://github.com/ivanahuckova) +* **Prometheus**: Use customQueryParameters for all queries. [#28949](https://github.com/grafana/grafana/pull/28949), [@alexbumbacea](https://github.com/alexbumbacea) +* **Security**: Prefer server cipher suites for http2. [#29379](https://github.com/grafana/grafana/pull/29379), [@bergquist](https://github.com/bergquist) +* **Security**: Remove insecure cipher suit as default option. [#29378](https://github.com/grafana/grafana/pull/29378), [@bergquist](https://github.com/bergquist) +* **StatPanels**: Add new calculation option for percentage difference. [#26369](https://github.com/grafana/grafana/pull/26369), [@jedstar](https://github.com/jedstar) +* **StatPanels**: Change default stats option to "Last (not null)". [#28617](https://github.com/grafana/grafana/pull/28617), [@ryantxu](https://github.com/ryantxu) +* **Table**: migrate old-table config to new table config. [#30142](https://github.com/grafana/grafana/pull/30142), [@jackw](https://github.com/jackw) +* **Templating**: Custom variable edit UI, change options input into textarea. [#28322](https://github.com/grafana/grafana/pull/28322), [@darrylsepeda](https://github.com/darrylsepeda) +* **TimeSeriesPanel**: The new graph panel now supports y-axis value mapping. [#30272](https://github.com/grafana/grafana/pull/30272), [@torkelo](https://github.com/torkelo) +* **Tracing**: Tag spans with user login and datasource name instead of id. [#29183](https://github.com/grafana/grafana/pull/29183), [@bergquist](https://github.com/bergquist) +* **Transformations**: Add "Rename By Regex" transformer. [#29281](https://github.com/grafana/grafana/pull/29281), [@simianhacker](https://github.com/simianhacker) +* **Transformations**: Added new transform for excluding and including rows based on their values. [#26884](https://github.com/grafana/grafana/pull/26884), [@Totalus](https://github.com/Totalus) +* **Transforms**: Add sort by transformer. [#30370](https://github.com/grafana/grafana/pull/30370), [@ryantxu](https://github.com/ryantxu) +* **Variables**: Add deprecation warning for value group tags. [#30160](https://github.com/grafana/grafana/pull/30160), [@torkelo](https://github.com/torkelo) +* **Variables**: Added __user.email to global variable. [#28853](https://github.com/grafana/grafana/pull/28853), [@mckn](https://github.com/mckn) +* **Variables**: Adds description field. [#29332](https://github.com/grafana/grafana/pull/29332), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Adds variables inspection. [#25214](https://github.com/grafana/grafana/pull/25214), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: New Variables are stored immediately. [#29178](https://github.com/grafana/grafana/pull/29178), [@hugohaggmark](https://github.com/hugohaggmark) +* **Zipkin**: Remove browser access mode. [#30360](https://github.com/grafana/grafana/pull/30360), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug fixes + +* **API**: Query database from /api/health endpoint. [#28349](https://github.com/grafana/grafana/pull/28349), [@ceh](https://github.com/ceh) +* **Alerting**: Return proper status code when trying to create alert notification channel with duplicate name or uid. [#28043](https://github.com/grafana/grafana/pull/28043), [@jgulick48](https://github.com/jgulick48) +* **Auth**: Fix default maximum lifetime an authenticated user can be logged in. [#30030](https://github.com/grafana/grafana/pull/30030), [@papagian](https://github.com/papagian) +* **Backend**: Fix IPv6 address parsing erroneous. [#28585](https://github.com/grafana/grafana/pull/28585), [@taciomcosta](https://github.com/taciomcosta) +* **CloudWatch**: Make sure stats grow horizontally and not vertically in the Query Editor. [#30106](https://github.com/grafana/grafana/pull/30106), [@sunker](https://github.com/sunker) +* **Cloudwatch**: Fix issue with field calculation transform not working properly with Cloudwatch data. [#28761](https://github.com/grafana/grafana/pull/28761), [@torkelo](https://github.com/torkelo) +* **Dashboards**: Hide playlist edit functionality from viewers and snapshots link from unauthenticated users. [#28992](https://github.com/grafana/grafana/pull/28992), [@jackw](https://github.com/jackw) +* **Data source proxy**: Convert 401 HTTP status code from data source to 400. [#28962](https://github.com/grafana/grafana/pull/28962), [@aknuds1](https://github.com/aknuds1) +* **Decimals**: Improving auto decimals logic for high numbers and scaled units. [#30262](https://github.com/grafana/grafana/pull/30262), [@torkelo](https://github.com/torkelo) +* **Elasticsearch**: Fix date histogram auto interval handling for alert queries. [#30049](https://github.com/grafana/grafana/pull/30049), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: Fix index pattern not working with multiple base sections. [#28348](https://github.com/grafana/grafana/pull/28348), [@tomdaly](https://github.com/tomdaly) +* **Explore**: Clear errors after running a new query. [#30367](https://github.com/grafana/grafana/pull/30367), [@ivanahuckova](https://github.com/ivanahuckova) +* **Graph**: Fixes stacking issues like floating bars when data is not aligned. [#29051](https://github.com/grafana/grafana/pull/29051), [@torkelo](https://github.com/torkelo) +* **Graph**: Staircase and null value=null calculates auto Y-Min incorrectly (fixed in new Time series panel). [#12995](https://github.com/grafana/grafana/issues/12995) +* **Graph**: Staircase mode, do now draw line segment from zero when drawing null values as null (Fixed in new Time series panel). [#17838](https://github.com/grafana/grafana/issues/17838) +* **Image uploader**: Fix uploading of images to GCS. [#26493](https://github.com/grafana/grafana/pull/26493), [@gastonqiu](https://github.com/gastonqiu) +* **Influx**: Fixes issue with many queries being issued as you type in the variable query field. [#29968](https://github.com/grafana/grafana/pull/29968), [@dprokop](https://github.com/dprokop) +* **Logs Panel**: Fix inconsistent highlighting. [#28971](https://github.com/grafana/grafana/pull/28971), [@ivanahuckova](https://github.com/ivanahuckova) +* **Logs Panel**: Fixes problem dragging scrollbar inside logs panel. [#28974](https://github.com/grafana/grafana/pull/28974), [@aocenas](https://github.com/aocenas) +* **Loki**: Fix hiding of series in table if labels have number values. [#30185](https://github.com/grafana/grafana/pull/30185), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Lower min step to 1ms. [#30135](https://github.com/grafana/grafana/pull/30135), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Remove showing of unique labels with the empty string value. [#30363](https://github.com/grafana/grafana/pull/30363), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Timeseries should not produce 0-values for missing data. [#30116](https://github.com/grafana/grafana/pull/30116), [@davkal](https://github.com/davkal) +* **Plugins**: Fix panic when using complex dynamic URLs in app plugin routes. [#27977](https://github.com/grafana/grafana/pull/27977), [@cinaglia](https://github.com/cinaglia) +* **Prometheus**: Fix link to Prometheus graph in dashboard. [#29543](https://github.com/grafana/grafana/pull/29543), [@ivanahuckova](https://github.com/ivanahuckova) +* **Provisioning**: Build paths in an os independent way. [#29143](https://github.com/grafana/grafana/pull/29143), [@amattheisen](https://github.com/amattheisen) +* **Provisioning**: Fixed problem with getting started panel being added to custom home dashboard. [#28750](https://github.com/grafana/grafana/pull/28750), [@torkelo](https://github.com/torkelo) +* **SAML**: Fixes bug in processing SAML response with empty element by updating saml library (Enterprise). [#29991](https://github.com/grafana/grafana/pull/29991), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **SQL**: Define primary key for tables without it. [#22255](https://github.com/grafana/grafana/pull/22255), [@azhiltsov](https://github.com/azhiltsov) +* **Tracing**: Fix issue showing more than 300 spans. [#29377](https://github.com/grafana/grafana/pull/29377), [@zoltanbedi](https://github.com/zoltanbedi) +* **Units**: Changes FLOP/s to FLOPS and some other rates per second units get /s suffix. [#28825](https://github.com/grafana/grafana/pull/28825), [@Berbe](https://github.com/Berbe) +* **Variables**: Fixes Constant variable persistence confusion. [#29407](https://github.com/grafana/grafana/pull/29407), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes Textbox current value persistence. [#29481](https://github.com/grafana/grafana/pull/29481), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes loading with a custom all value in url. [#28958](https://github.com/grafana/grafana/pull/28958), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes so clicking on Selected in drop down will exclude All value from selection. [#29844](https://github.com/grafana/grafana/pull/29844), [@hugohaggmark](https://github.com/hugohaggmark) + +### Breaking changes + + +#### Constant variables + +In order to minimize the confusion with Constant variable usage, we've removed the ability to make Constant variables visible. This change will also migrate __`all`__ existing __`visible`__ Constant variables to Textbox variables because which we think this is a more appropriate type of variable for this use case. + Issue [#29407](https://github.com/grafana/grafana/issues/29407) + + +#### Plugin compatibility + +We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require a small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an `$onInit` function. For more details on how to migrate AngularJS code open the [migration guide](https://docs.angularjs.org/guide/migration) and search for **pre-assigning bindings**. + +In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/master/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. Issue [#28736](https://github.com/grafana/grafana/issues/28736) + +### Deprecations + + +#### Query variable value group tags + +This option to group query variable values into groups by tags has been an experimental feature since it was introduced. It was introduced to work around the lack of tags support in time series databases at the time. Now that tags (ie. labels) are the norm there is no longer any great need for this feature. This feature will be removed in Grafana v8 later this year. Issue [#30160](https://github.com/grafana/grafana/issues/30160) + +### Plugin development fixes & changes + +* **AngularPlugins**: Angular controller events emitter is now a separate emitter and not the same as PanelModel events emitter. [#30379](https://github.com/grafana/grafana/pull/30379), [@torkelo](https://github.com/torkelo) +* **FieldConfig API**: Add ability to hide field option or disable it from the overrides. [#29879](https://github.com/grafana/grafana/pull/29879), [@dprokop](https://github.com/dprokop) +* **Select**: Changes default menu placement for Select from auto to bottom. [#29837](https://github.com/grafana/grafana/pull/29837), [@hugohaggmark](https://github.com/hugohaggmark) +* **Collapse**: Allow component children to use height: 100% styling. [#29776](https://github.com/grafana/grafana/pull/29776), [@aocenas](https://github.com/aocenas) +* **DataSourceWithBackend**: Throw error if health check fails in DataSourceWithBackend. [#29743](https://github.com/grafana/grafana/pull/29743), [@aocenas](https://github.com/aocenas) +* **NodeGraph**: Add node graph visualization. [#29706](https://github.com/grafana/grafana/pull/29706), [@aocenas](https://github.com/aocenas) +* **FieldColor**: Handling color changes when switching panel types. [#28875](https://github.com/grafana/grafana/pull/28875), [@dprokop](https://github.com/dprokop) +* **CodeEditor**: Added support for javascript language. [#28818](https://github.com/grafana/grafana/pull/28818), [@ae3e](https://github.com/ae3e) +* **grafana/toolkit**: Allow builds with lint warnings. [#28810](https://github.com/grafana/grafana/pull/28810), [@dprokop](https://github.com/dprokop) +* **grafana/toolkit**: Drop console and debugger statements by default when building plugin. [#28776](https://github.com/grafana/grafana/pull/28776), [@dprokop](https://github.com/dprokop) +* **Card**: Add new Card component. [#28216](https://github.com/grafana/grafana/pull/28216), [@Clarity-89](https://github.com/Clarity-89) +* **FieldConfig**: Implementation slider editor (#27592). [#28007](https://github.com/grafana/grafana/pull/28007), [@isaozlerfm](https://github.com/isaozlerfm) +* **MutableDataFrame**: Remove unique field name constraint and values field index and unused/seldom used stuff. [#27573](https://github.com/grafana/grafana/pull/27573), [@torkelo](https://github.com/torkelo) + + + + + +# 7.3.10 (2021-03-18) + +### Bug fixes + +* **Security**: Fix API permissions issues related to team-sync CVE-2021-28146, CVE-2021-28147. (Enterprise) +* **Security**: Usage insights requires signed in users CVE-2021-28148. (Enterprise) + + + + + +# 7.3.7 (2021-01-14) + +### Bug fixes + +* **Auth**: Add missing request headers to SigV4 middleware allowlist. [#30115](https://github.com/grafana/grafana/pull/30115), [@wbrowne](https://github.com/wbrowne) +* **Elasticsearch**: Sort results by index order as well as @timestamp. [#29761](https://github.com/grafana/grafana/pull/29761), [@STEELBADGE](https://github.com/STEELBADGE) +* **SAML**: Fixes bug in processing SAML response with empty element by updating saml library (Enterprise). [#30179](https://github.com/grafana/grafana/pull/30179), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **SeriesToRows**: Fixes issue in transform so that value field is always named Value. [#30054](https://github.com/grafana/grafana/pull/30054), [@torkelo](https://github.com/torkelo) + + + + + +# 7.3.6 (2020-12-17) + +### Security + +* **SAML**: Fixes encoding/xml SAML vulnerability in Grafana Enterprise. [#29875](https://github.com/grafana/grafana/issues/29875) + + + + + +# 7.3.5 (2020-12-10) + +### Features and enhancements + +* **Alerting**: Improve Prometheus Alert Rule error message. [#29390](https://github.com/grafana/grafana/pull/29390), [@wbrowne](https://github.com/wbrowne) + +### Bug fixes + +* **Alerting**: Fix alarm message formatting in Dingding. [#29482](https://github.com/grafana/grafana/pull/29482), [@tomowang](https://github.com/tomowang) +* **AzureMonitor**: Fix unit translation for MilliSeconds. [#29399](https://github.com/grafana/grafana/pull/29399), [@secustor](https://github.com/secustor) +* **Instrumentation**: Fix bug with invalid handler label value for HTTP request metrics. [#29529](https://github.com/grafana/grafana/pull/29529), [@bergquist](https://github.com/bergquist) +* **Prometheus**: Fixes problem where changing display name in Field tab had no effect. [#29441](https://github.com/grafana/grafana/pull/29441), [@zoltanbedi](https://github.com/zoltanbedi) +* **Tracing**: Fixed issue showing more than 300 spans. [#29377](https://github.com/grafana/grafana/pull/29377), [@zoltanbedi](https://github.com/zoltanbedi) + + + + + +# 7.3.4 (2020-11-24) + +### Bug fixes + +* **Dashboard**: Fixes kiosk state after being redirected to login page and back. [#29273](https://github.com/grafana/grafana/pull/29273), [@torkelo](https://github.com/torkelo) +* **InfluxDB**: Update flux library to fix support for boolean label values. [#29310](https://github.com/grafana/grafana/pull/29310), [@ryantxu](https://github.com/ryantxu) +* **Security**: Fixes minor security issue with alert notification webhooks that allowed GET & DELETE requests. [#29330](https://github.com/grafana/grafana/pull/29330), [@wbrowne](https://github.com/wbrowne) +* **Table**: Fixes issues with phantom extra 0 for zero values. [#29165](https://github.com/grafana/grafana/pull/29165), [@dprokop](https://github.com/dprokop) + + + + + +# 7.3.3 (2020-11-17) + +### Bug fixes + +* **Cloud monitoring**: Fix for multi-value template variable for project selector. [#29042](https://github.com/grafana/grafana/pull/29042), [@papagian](https://github.com/papagian) +* **LogsPanel**: Fixes problem dragging scrollbar inside logs panel. [#28974](https://github.com/grafana/grafana/pull/28974), [@aocenas](https://github.com/aocenas) +* **Provisioning**: Fixes application not pinned to the sidebar when it's enabled. [#29084](https://github.com/grafana/grafana/pull/29084), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **StatPanel**: Fixes hanging issue when all values are zero. [#29077](https://github.com/grafana/grafana/pull/29077), [@torkelo](https://github.com/torkelo) +* **Thresholds**: Fixes color assigned to null values. [#29010](https://github.com/grafana/grafana/pull/29010), [@torkelo](https://github.com/torkelo) + + + + + +# 7.3.2 (2020-11-11) + + ### Features / Enhancements + * **CloudWatch Logs**: Change how we measure query progress. [#28912](https://github.com/grafana/grafana/pull/28912), [@aocenas](https://github.com/aocenas) + * **Dashboards / Folders**: delete related data (permissions, stars, tags, versions, annotations) when deleting a dashboard or a folder. [#28826](https://github.com/grafana/grafana/pull/28826), [@AgnesToulet](https://github.com/AgnesToulet) + * **Gauge**: Improve font size auto sizing. [#28797](https://github.com/grafana/grafana/pull/28797), [@torkelo](https://github.com/torkelo) + * **Short URL**: Cleanup unvisited/stale short URLs. [#28867](https://github.com/grafana/grafana/pull/28867), [@wbrowne](https://github.com/wbrowne) + * **Templating**: Custom variable edit UI, change options input into textarea. [#28322](https://github.com/grafana/grafana/pull/28322), [@darrylsepeda](https://github.com/darrylsepeda) + + ### Bug Fixes + * **Cloudwatch**: Fix issue with field calculation transform not working properly with Cloudwatch data. [#28761](https://github.com/grafana/grafana/pull/28761), [@torkelo](https://github.com/torkelo) + * **Dashboard**: fix view panel mode for Safari / iOS. [#28702](https://github.com/grafana/grafana/pull/28702), [@jackw](https://github.com/jackw) + * **Elasticsearch**: Exclude pipeline aggregations from order by options. [#28620](https://github.com/grafana/grafana/pull/28620), [@simianhacker](https://github.com/simianhacker) + * **Panel inspect**: Interpolate variables in panel inspect title. [#28779](https://github.com/grafana/grafana/pull/28779), [@dprokop](https://github.com/dprokop) + * **Prometheus**: Fix copy paste behaving as cut and paste. [#28622](https://github.com/grafana/grafana/pull/28622), [@aocenas](https://github.com/aocenas) + * **StatPanels**: Fixes auto min max when latest value is zero. [#28982](https://github.com/grafana/grafana/pull/28982), [@torkelo](https://github.com/torkelo) + * **TableFilters**: Fixes filtering with field overrides. [#28690](https://github.com/grafana/grafana/pull/28690), [@hugohaggmark](https://github.com/hugohaggmark) + * **Templating**: Speeds up certain variable queries for Postgres MySql MSSql. [#28686](https://github.com/grafana/grafana/pull/28686), [@hugohaggmark](https://github.com/hugohaggmark) + * **Units**: added support to handle negative fractional numbers. [#28849](https://github.com/grafana/grafana/pull/28849), [@mckn](https://github.com/mckn) + * **Variables**: Fix backward compatibility in custom variable options that contain colon. [#28896](https://github.com/grafana/grafana/pull/28896), [@mckn](https://github.com/mckn) + + + +# 7.3.1 (2020-10-30) + + ### Bug Fixes + * **Cloudwatch**: Fix duplicate metric data. [#28642](https://github.com/grafana/grafana/pull/28642), [@zoltanbedi](https://github.com/zoltanbedi) + * **Loki**: Fix error when some queries return zero results. [#28645](https://github.com/grafana/grafana/pull/28645), [@ivanahuckova](https://github.com/ivanahuckova) + * **PanelMenu**: Fix panel submenu not being accessible for panels close to the right edge of the screen. [#28666](https://github.com/grafana/grafana/pull/28666), [@torkelo](https://github.com/torkelo) + * **Plugins**: Fix descendent frontend plugin signature validation. [#28638](https://github.com/grafana/grafana/pull/28638), [@wbrowne](https://github.com/wbrowne) + * **StatPanel**: Fix value being under graph and reduced likelihood for white and dark value text mixing. [#28641](https://github.com/grafana/grafana/pull/28641), [@torkelo](https://github.com/torkelo) + * **TextPanel**: Fix problems where text panel would show old content. [#28643](https://github.com/grafana/grafana/pull/28643), [@torkelo](https://github.com/torkelo) + +# 7.3.0 (2020-10-28) + + ### Features / Enhancements + * **AzureMonitor**: Support decimal (as float64) type in analytics/logs. [#28480](https://github.com/grafana/grafana/pull/28480), [@kylebrandt](https://github.com/kylebrandt) + * **Plugins signing**: UI information. [#28469](https://github.com/grafana/grafana/pull/28469), [@dprokop](https://github.com/dprokop) + * **Short URL**: Update last seen at when visiting a short URL. [#28565](https://github.com/grafana/grafana/pull/28565), [@marefr](https://github.com/marefr) + + ### Bug Fixes + * **Alerting**: Log warnings for obsolete notifiers when extracting alerts and remove frequent error log messages. [#28162](https://github.com/grafana/grafana/pull/28162), [@papagian](https://github.com/papagian) + * **Auth**: Fix SigV4 request verification step for Amazon Elasticsearch Service. [#28481](https://github.com/grafana/grafana/pull/28481), [@wbrowne](https://github.com/wbrowne) + * **Auth**: Should redirect to login when anonymous enabled and URL with different org than anonymous specified. [#28158](https://github.com/grafana/grafana/pull/28158), [@marefr](https://github.com/marefr) + * **Elasticsearch**: Fix handling of errors when testing data source. [#28498](https://github.com/grafana/grafana/pull/28498), [@marefr](https://github.com/marefr) + * **Graphite**: Fix default version to be 1.1. [#28471](https://github.com/grafana/grafana/pull/28471), [@ivanahuckova](https://github.com/ivanahuckova) + * **StatPanel**: Fixes BizChart error max: yyy should not be less than min zzz. [#28587](https://github.com/grafana/grafana/pull/28587), [@hugohaggmark](https://github.com/hugohaggmark) + + +# 7.3.0-beta2 (2020-10-22) + +### Features / Enhancements +* **Add monitoring mixing for Grafana**. [#28285](https://github.com/grafana/grafana/pull/28285), [@bergquist](https://github.com/bergquist) +* **CloudWatch**: Missing Namespace AWS/EC2CapacityReservations. [#28309](https://github.com/grafana/grafana/pull/28309), [@nonamef](https://github.com/nonamef) +* **Explore**: Support wide data frames. [#28393](https://github.com/grafana/grafana/pull/28393), [@aocenas](https://github.com/aocenas) +* **Instrumentation**: Add counters and histograms for database queries. [#28236](https://github.com/grafana/grafana/pull/28236), [@bergquist](https://github.com/bergquist) +* **Loki**: Visually distinguish error logs for LogQL2. [#28359](https://github.com/grafana/grafana/pull/28359), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug Fixes +* **API**: Fix short URLs. [#28300](https://github.com/grafana/grafana/pull/28300), [@aknuds1](https://github.com/aknuds1) +* **BackendSrv**: Fixes queue countdown when unsubscribe is before response. [#28323](https://github.com/grafana/grafana/pull/28323), [@hugohaggmark](https://github.com/hugohaggmark) +* **CloudWatch/Athena - valid metrics and dimensions.**. [#28436](https://github.com/grafana/grafana/pull/28436), [@kwarunek](https://github.com/kwarunek) +* **Dashboard links**: Places drop down list so it's always visible. [#28330](https://github.com/grafana/grafana/pull/28330), [@maknik](https://github.com/maknik) +* **Graph**: Fix for graph size not taking up full height or width. [#28314](https://github.com/grafana/grafana/pull/28314), [@jackw](https://github.com/jackw) +* **Loki**: Base maxDataPoints limits on query type. [#28298](https://github.com/grafana/grafana/pull/28298), [@aocenas](https://github.com/aocenas) +* **Loki**: Run instant query only when doing metric query. [#28325](https://github.com/grafana/grafana/pull/28325), [@aocenas](https://github.com/aocenas) +* **Plugins**: Don't exit on duplicate plugin. [#28390](https://github.com/grafana/grafana/pull/28390), [@aknuds1](https://github.com/aknuds1) + +# 7.3.0-beta1 (2020-10-15) + +### Breaking changes + +- **CloudWatch**: The AWS CloudWatch data source's authentication scheme has changed. See the [upgrade notes](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v73) for details and how this may affect you. +- **Docker**: The Grafana docker image will run with the root group instead of the Grafana group. This may break builds for users who extend the official Docker images. Refer to the [upgrade notes](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v73) for details. + +### Features / Enhancements +* **Alerting**: Add labels to name when converting data frame to series. [#28085](https://github.com/grafana/grafana/pull/28085), [@kylebrandt](https://github.com/kylebrandt) +* **Alerting**: Ensuring LINE Notify notifications are sent for all alert states. [#27639](https://github.com/grafana/grafana/pull/27639), [@haraldkubota](https://github.com/haraldkubota) +* **Auth**: Add SigV4 auth option to datasources. [#27552](https://github.com/grafana/grafana/pull/27552), [@wbrowne](https://github.com/wbrowne) +* **AzureMonitor**: Pass through null values instead of setting 0. [#28126](https://github.com/grafana/grafana/pull/28126), [@kylebrandt](https://github.com/kylebrandt) +* **Cloud Monitoring**: Out-of-the-box dashboards. [#27864](https://github.com/grafana/grafana/pull/27864), [@papagian](https://github.com/papagian) +* **CloudWatch**: Add support for AWS DirectConnect virtual interface metrics and add missing dimensions. [#28008](https://github.com/grafana/grafana/pull/28008), [@jgulick48](https://github.com/jgulick48) +* **CloudWatch**: Adding support for Amazon ElastiCache Redis metrics. [#28040](https://github.com/grafana/grafana/pull/28040), [@jgulick48](https://github.com/jgulick48) +* **CloudWatch**: Adding support for additional Amazon CloudFront metrics. [#28069](https://github.com/grafana/grafana/pull/28069), [@darrylsepeda](https://github.com/darrylsepeda) +* **CloudWatch**: Re-implement authentication. [#25548](https://github.com/grafana/grafana/pull/25548), [@aknuds1](https://github.com/aknuds1),[@patstrom](https://github.com/patstrom) +* **Dashboard**: Allow shortlink generation. [#27409](https://github.com/grafana/grafana/pull/27409), [@MisterSquishy](https://github.com/MisterSquishy) +* **Docker**: OpenShift compatability. [#27813](https://github.com/grafana/grafana/pull/27813), [@xlson](https://github.com/xlson) +* **Elasticsearch**: Support multiple pipeline aggregations for a query. [#27945](https://github.com/grafana/grafana/pull/27945), [@simianhacker](https://github.com/simianhacker) +* **Explore**: Allow shortlink generation. [#28222](https://github.com/grafana/grafana/pull/28222), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: Remove collapsing of visualisations. [#27026](https://github.com/grafana/grafana/pull/27026), [@ivanahuckova](https://github.com/ivanahuckova) +* **FieldColor**: Adds new standard color option for color. [#28039](https://github.com/grafana/grafana/pull/28039), [@torkelo](https://github.com/torkelo) +* **Gauge**: Improve text sizing and support non threshold color modes. [#28256](https://github.com/grafana/grafana/pull/28256), [@torkelo](https://github.com/torkelo) +* **NamedColors**: Named colors refactors. [#28235](https://github.com/grafana/grafana/pull/28235), [@torkelo](https://github.com/torkelo) +* **Panel Inspect**: Allow CSV download for Excel. [#27284](https://github.com/grafana/grafana/pull/27284), [@tomdaly](https://github.com/tomdaly) +* **Prometheus**: Add time range parameters to labels API. [#27548](https://github.com/grafana/grafana/pull/27548), [@kakkoyun](https://github.com/kakkoyun) +* **Snapshots**: Store dashboard data encrypted in the database. [#28129](https://github.com/grafana/grafana/pull/28129), [@wbrowne](https://github.com/wbrowne) +* **Table**: New cell hover behavior and image cell display mode. [#27669](https://github.com/grafana/grafana/pull/27669), [@torkelo](https://github.com/torkelo) +* **Timezones**: Include IANA timezone canonical name in TimeZoneInfo. [#27591](https://github.com/grafana/grafana/pull/27591), [@dprokop](https://github.com/dprokop) +* **Tracing**: Add Tempo data source. [#28204](https://github.com/grafana/grafana/pull/28204), [@aocenas](https://github.com/aocenas) +* **Transformations**: Add Concatenate fields transformer. [#28237](https://github.com/grafana/grafana/pull/28237), [@ryantxu](https://github.com/ryantxu) +* **Transformations**: improve the reduce transformer. [#27875](https://github.com/grafana/grafana/pull/27875), [@ryantxu](https://github.com/ryantxu) +* **Users**: Expire old user invites. [#27361](https://github.com/grafana/grafana/pull/27361), [@wbrowne](https://github.com/wbrowne) +* **Variables**: Adds loading state and indicators. [#27917](https://github.com/grafana/grafana/pull/27917), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Adds support for key/value mapping in Custom variable. [#27829](https://github.com/grafana/grafana/pull/27829), [@sartaj10](https://github.com/sartaj10) +* **grafana/toolkit**: expose Jest maxWorkers arg for plugin test & build tasks. [#27724](https://github.com/grafana/grafana/pull/27724), [@domasx2](https://github.com/domasx2) + +### Bug Fixes +* **Azure Analytics**: FormatAs Time series groups bool columns wrong. [#27713](https://github.com/grafana/grafana/issues/27713) +* **Azure**: Fixes cancellation of requests with different Azure sources. [#28180](https://github.com/grafana/grafana/pull/28180), [@hugohaggmark](https://github.com/hugohaggmark) +* **BackendSrv**: Reloads page instead of redirect on Unauthorized Error. [#28276](https://github.com/grafana/grafana/pull/28276), [@hugohaggmark](https://github.com/hugohaggmark) +* **Dashboard**: Do not allow users without edit permission to a folder to see new dashboard page. [#28249](https://github.com/grafana/grafana/pull/28249), [@torkelo](https://github.com/torkelo) +* **Dashboard**: Fixed issue accessing horizontal table scrollbar when placed at bottom of dashboard. [#28250](https://github.com/grafana/grafana/pull/28250), [@torkelo](https://github.com/torkelo) +* **DataProxy**: Add additional settings for dataproxy to help with network proxy timeouts. [#27841](https://github.com/grafana/grafana/pull/27841), [@kahinton](https://github.com/kahinton) +* **Database**: Adds new indices to alert_notification_state and alert_rule_tag tables. [#28166](https://github.com/grafana/grafana/pull/28166), [@KarineValenca](https://github.com/KarineValenca) +* **Explore**: Fix showing of Prometheus data in Query inspector. [#28128](https://github.com/grafana/grafana/pull/28128), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: Show results of Prometheus instant queries in formatted table. [#27767](https://github.com/grafana/grafana/pull/27767), [@ivanahuckova](https://github.com/ivanahuckova) +* **Graph**: Prevent legend from overflowing container. [#28254](https://github.com/grafana/grafana/pull/28254), [@jackw](https://github.com/jackw) +* **OAuth**: Fix token refresh failure when custom SSL settings are configured for OAuth provider. [#27523](https://github.com/grafana/grafana/pull/27523), [@billoley](https://github.com/billoley) +* **Plugins**: Let descendant plugins inherit their root's signature. [#27970](https://github.com/grafana/grafana/pull/27970), [@aknuds1](https://github.com/aknuds1) +* **Runtime**: Fix handling of short-lived background services. [#28025](https://github.com/grafana/grafana/pull/28025), [@ahlaw](https://github.com/ahlaw) +* **TemplateSrv**: Fix interpolating strings with object variables. [#28171](https://github.com/grafana/grafana/pull/28171), [@torkelo](https://github.com/torkelo) +* **Variables**: Fixes so constants set from url get completed state. [#28257](https://github.com/grafana/grafana/pull/28257), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Prevent adhoc filters from crashing when they are not loaded properly. [#28226](https://github.com/grafana/grafana/pull/28226), [@mckn](https://github.com/mckn) + + + +# 7.2.3 (2020-12-17) + +### Security + +- **SAML**: Fixes encoding/xml SAML vulnerability in Grafana Enterprise [#29875](https://github.com/grafana/grafana/issues/29875), [@bergquist](https://github.com/bergquist) + + + +# 7.2.2 (2020-10-21) + +### Features / Enhancements + +**Caution:** Please do not use/enable the `database_metrics` feature flag. It will corrupt MySQL database tables. See [#28440](https://github.com/grafana/grafana/issues/28440) for more information. + +~~**Instrumentation**: Add counters and histograms for database queries. [#28236](https://github.com/grafana/grafana/pull/28236), [@bergquist](https://github.com/bergquist)~~ + +- **Instrumentation**: Add histogram for request duration. [#28364](https://github.com/grafana/grafana/pull/28364), [@bergquist](https://github.com/bergquist) +- **Instrumentation**: Adds environment_info metric. [#28355](https://github.com/grafana/grafana/pull/28355), [@bergquist](https://github.com/bergquist) + +### Bug Fixes + +- **CloudWatch**: Fix custom metrics. [#28391](https://github.com/grafana/grafana/pull/28391), [@aknuds1](https://github.com/aknuds1) + +# 7.2.1 (2020-10-08) + +### Features / Enhancements +* **Api**: Add /healthz endpoint for health checks. [#27536](https://github.com/grafana/grafana/pull/27536), [@bergquist](https://github.com/bergquist) +* **Api**: Healthchecks should not be rejected due to domain enforcement checks. [#27981](https://github.com/grafana/grafana/pull/27981), [@bergquist](https://github.com/bergquist) +* **Instrumentation**: Removes invalid chars from label names. [#27921](https://github.com/grafana/grafana/pull/27921), [@bergquist](https://github.com/bergquist) +* **Orgs**: Remove organisations deprecation notice from backend. [#27788](https://github.com/grafana/grafana/pull/27788), [@wbrowne](https://github.com/wbrowne) +* **grafana/toolkit**: Add --coverage flag to plugin build command. [#27743](https://github.com/grafana/grafana/pull/27743), [@gassiss](https://github.com/gassiss) + +### Bug Fixes +* **BarGauge**: Fixed scrollbar showing for bar gauge in Firefox. [#27784](https://github.com/grafana/grafana/pull/27784), [@torkelo](https://github.com/torkelo) +* **Dashboard**: Honour root_url for Explore link. [#27654](https://github.com/grafana/grafana/pull/27654), [@tiagomotasantos](https://github.com/tiagomotasantos) +* **DashboardLinks**: values in links are updated when variables change. [#27926](https://github.com/grafana/grafana/pull/27926), [@hugohaggmark](https://github.com/hugohaggmark) +* **Elasticsearch**: Add query's refId to each series returned by a query. [#27614](https://github.com/grafana/grafana/pull/27614), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Fix ad-hoc filter support for Raw Data query and new table panel. [#28064](https://github.com/grafana/grafana/pull/28064), [@Elfo404](https://github.com/Elfo404) +* **Graph**: Fixed histogram bucket calculations to avoid missing buckets. [#27883](https://github.com/grafana/grafana/pull/27883), [@torkelo](https://github.com/torkelo) +* **Loki**: Run instant query only in Explore. [#27974](https://github.com/grafana/grafana/pull/27974), [@ivanahuckova](https://github.com/ivanahuckova) +* **Units**: bps & Bps default scale remains decimal (backwards-compatibility). [#27838](https://github.com/grafana/grafana/pull/27838), [@Berbe](https://github.com/Berbe) +* **ValueMappings**: Fix issue with value mappings in override applying to all columns. [#27718](https://github.com/grafana/grafana/pull/27718), [@torkelo](https://github.com/torkelo) + +# 7.2.0 (2020-09-23) + +### Features / Enhancements +- **Alerting**: Ensuring notifications displayed correctly in mobile device with Google Chat. [#27578](https://github.com/grafana/grafana/pull/27578), [@alvarolmedo](https://github.com/alvarolmedo) +- **TraceView**: Show full traceID and better discern multiple stackTraces in span details. [#27710](https://github.com/grafana/grafana/pull/27710), [@aocenas](https://github.com/aocenas) + +### Bug Fixes +- **DataLinks**: Fixes issue with data links not interpolating values with correct field config. [#27622](https://github.com/grafana/grafana/pull/27622), [@torkelo](https://github.com/torkelo) +- **DataProxy**: Ignore empty URL's in plugin routes. [#27653](https://github.com/grafana/grafana/pull/27653), [@domasx2](https://github.com/domasx2) +- **Field config**: Respect config paths when rendering default value of field config property. [#27652](https://github.com/grafana/grafana/pull/27652), [@dprokop](https://github.com/dprokop) +- **Field config**: Fix mismatch in field config editor types. [#27657](https://github.com/grafana/grafana/pull/27657), [@dprokop](https://github.com/dprokop) +- **Panel editor**: Prevents adding transformations in panels with alerts. [#27706](https://github.com/grafana/grafana/pull/27706), [@hugohaggmark](https://github.com/hugohaggmark) +- **Stat panel**: Fix problem where string values where always green. [#27656](https://github.com/grafana/grafana/pull/27656), [@peterholmberg](https://github.com/peterholmberg) + +# 7.2.0-beta2 (2020-09-17) + +### Features / Enhancements + - **API**: Enrich add user to org endpoints with user ID in the response. [#27551](https://github.com/grafana/grafana/pull/27551), [@AgnesToulet](https://github.com/AgnesToulet) + - **API**: Enrich responses and improve error handling for alerting API endpoints. [#27550](https://github.com/grafana/grafana/pull/27550), [@AgnesToulet](https://github.com/AgnesToulet) + - **Auth**: Replace maximum inactive/lifetime settings of days to duration. [#27150](https://github.com/grafana/grafana/pull/27150), [@Hansuuuuuuuuuu](https://github.com/Hansuuuuuuuuuu) + - **Dashboard**: Support configuring default timezone via config file. [#27404](https://github.com/grafana/grafana/pull/27404), [@woutersmeenk](https://github.com/woutersmeenk) + - **Elasticsearch**: Add support for date_nanos type. [#27538](https://github.com/grafana/grafana/pull/27538), [@Elfo404](https://github.com/Elfo404) + - **Elasticsearch**: Allow fields starting with underscore. [#27520](https://github.com/grafana/grafana/pull/27520), [@Elfo404](https://github.com/Elfo404) + - **Elasticsearch**: Increase maximum geohash aggregation precision to 12. [#27539](https://github.com/grafana/grafana/pull/27539), [@Elfo404](https://github.com/Elfo404) + - **Field config**: Add support for paths in default field config setup. [#27570](https://github.com/grafana/grafana/pull/27570), [@dprokop](https://github.com/dprokop) + - **Postgres**: Support request cancellation properly (Uses new backendSrv.fetch Observable request API). [#27478](https://github.com/grafana/grafana/pull/27478), [@hugohaggmark](https://github.com/hugohaggmark) + - **Provisioning**: Remove provisioned dashboards without parental reader. [#26143](https://github.com/grafana/grafana/pull/26143), [@nabokihms](https://github.com/nabokihms) + - **Variables**: Limit rendering of options in dropdown to improve search performance. [#27525](https://github.com/grafana/grafana/pull/27525), [@guoqn](https://github.com/guoqn) + - **Units**: Binary-prefixed data rates. [#27022](https://github.com/grafana/grafana/pull/27022), [@Berbe](https://github.com/Berbe) + + ### Bug Fixes + - **Admin**: Fixes close('X') button layout issue in API keys page. [#27625](https://github.com/grafana/grafana/pull/27625), [@nikasvan](https://github.com/nikasvan) + - **Alerting**: Fix integration key so it's stored encrypted for Pagerduty notifier. [#27484](https://github.com/grafana/grafana/pull/27484), [@marefr](https://github.com/marefr) + - **Annotations**: Fixes issue with showing error notice for cancelled annotation queries. [#27557](https://github.com/grafana/grafana/pull/27557), [@torkelo](https://github.com/torkelo) + - **Azure/Insights**: Fix handling of legacy dimension values. [#27513](https://github.com/grafana/grafana/pull/27513), [@marefr](https://github.com/marefr) + - **DataLinks**: Respects display name and adds field quoting. [#27616](https://github.com/grafana/grafana/pull/27616), [@hugohaggmark](https://github.com/hugohaggmark) + - **ImageRendering**: Fix rendering panel using shared query in png, PDF reports and embedded scenarios. [#27628](https://github.com/grafana/grafana/pull/27628), [@torkelo](https://github.com/torkelo) + - **InputControl**: Fixed using InputControl in unit tests from plugins. [#27615](https://github.com/grafana/grafana/pull/27615), [@torkelo](https://github.com/torkelo) + - **NewsPanel**: Fixed XSS issue when rendering rss links. [#27612](https://github.com/grafana/grafana/pull/27612), [@torkelo](https://github.com/torkelo) + - **Transforms**: Fix for issue in labels to fields transform where the new option value field name did not work properly. [#27501](https://github.com/grafana/grafana/pull/27501), [@torkelo](https://github.com/torkelo) + +# 7.2.0-beta1 (2020-09-09) + +### Breaking changes + +- **Units**: The date time units `YYYY-MM-DD HH:mm:ss` and `MM/DD/YYYY h:mm:ss a` have been renamed to `Datetime ISO` + and `Datetime US` respectively. This is no breaking change just a visual name change (the unit id is unchanged). The + unit behavior is different however, it no longer hides the date part if the date is today. If you want this old + behavior you need to change unit to `Datetime ISO (No date if today)` or `Datetime US (No date if today)`. + +### Features / Enhancements + +- **API**: Return ID of the deleted resource for dashboard, datasource and folder DELETE endpoints. [#26691](https://github.com/grafana/grafana/pull/26691), [@AgnesToulet](https://github.com/AgnesToulet) +- **API**: Support paging in the admin orgs list API. [#26932](https://github.com/grafana/grafana/pull/26932), [@benjaminjb](https://github.com/benjaminjb) +- **API**: return resource ID for auth key creation, folder permissions update and user invite complete endpoints. [#27419](https://github.com/grafana/grafana/pull/27419), [@AgnesToulet](https://github.com/AgnesToulet) +- **Alerting**: Add toggle to disable alert threshold visibility in graph panel. [#25705](https://github.com/grafana/grafana/pull/25705), [@jpalpant](https://github.com/jpalpant) +- **Alerting**: Adds support for overriding 'dedup_key' via alert tags when using the Pagerduty notifier. [#27356](https://github.com/grafana/grafana/pull/27356), [@alavrovinfb](https://github.com/alavrovinfb) +- **Alerting**: Change alert rule link in alert notifications to open panel in view mode. [#27378](https://github.com/grafana/grafana/pull/27378), [@robertlestak](https://github.com/robertlestak) +- **Alerting**: Support storing sensitive notifier settings securely/encrypted. [#25114](https://github.com/grafana/grafana/pull/25114), [@mtanda](https://github.com/mtanda) +- **Annotation**: Add clean up job for old annotations. [#26156](https://github.com/grafana/grafana/pull/26156), [@bergquist](https://github.com/bergquist) +- **AzureMonitor**: select plugin route from cloudname. [#27273](https://github.com/grafana/grafana/pull/27273), [@kylebrandt](https://github.com/kylebrandt) +- **BackendSrv**: Uses credentials, deprecates withCredentials & defaults to same-origin. [#27385](https://github.com/grafana/grafana/pull/27385), [@hugohaggmark](https://github.com/hugohaggmark) +- **Chore**: Upgrade to Go 1.15.1. [#27326](https://github.com/grafana/grafana/pull/27326), [@aknuds1](https://github.com/aknuds1) +- **CloudWatch**: Update list of AmazonMQ metrics and dimensions. [#27332](https://github.com/grafana/grafana/pull/27332), [@szymonpk](https://github.com/szymonpk) +- **Cloudwatch**: Add Support for external ID in assume role. [#23685](https://github.com/grafana/grafana/pull/23685), [@gdhananjay](https://github.com/gdhananjay) +- **Cloudwatch**: Add af-south-1 region. [#26513](https://github.com/grafana/grafana/pull/26513), [@ruanbekker](https://github.com/ruanbekker) +- **Dashboard**: Add Duplicate dashboard links button to links list. [#26600](https://github.com/grafana/grafana/pull/26600), [@Hmerac](https://github.com/Hmerac) +- **Dashboard**: Adds folder name and link to the dashboard overview on the homepage. [#27214](https://github.com/grafana/grafana/pull/27214), [@michelengelen](https://github.com/michelengelen) +- **Database**: Set 0640 permissions on SQLite database file. [#26339](https://github.com/grafana/grafana/pull/26339), [@aknuds1](https://github.com/aknuds1) +- **DateFormats**: Default ISO & US formats never omit date part even if date is today (breaking change). [#27300](https://github.com/grafana/grafana/pull/27300), [@torkelo](https://github.com/torkelo) +- **Explore/Loki**: POC for toggling parsed fields in the list view. [#26178](https://github.com/grafana/grafana/pull/26178), [@fredr](https://github.com/fredr) +- **Explore**: Sort order of log results. [#26669](https://github.com/grafana/grafana/pull/26669), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Transform prometheus query to elasticsearch query. [#23670](https://github.com/grafana/grafana/pull/23670), [@melchiormoulin](https://github.com/melchiormoulin) +- **Field overrides**: Overrides UI improvements. [#27073](https://github.com/grafana/grafana/pull/27073), [@dprokop](https://github.com/dprokop) +- **Heatmap**: Reduce the aggressiveness of hiding ticks/labels when panel is small. [#27016](https://github.com/grafana/grafana/pull/27016), [@lrstanley](https://github.com/lrstanley) +- **Image Store**: Add support for using signed URLs when uploading images to GCS. [#26840](https://github.com/grafana/grafana/pull/26840), [@marcosrmendezthd](https://github.com/marcosrmendezthd) +- **Image Store**: Fallback to application default credentials when no key file is specified for GCS. [#25948](https://github.com/grafana/grafana/pull/25948), [@Eraac](https://github.com/Eraac) +- **InfluxDB/Flux**: Increase series limit for Flux datasource. [#26746](https://github.com/grafana/grafana/pull/26746), [@sneddrs](https://github.com/sneddrs) +- **InfluxDB**: exclude result and table column from Flux table results. [#27081](https://github.com/grafana/grafana/pull/27081), [@ryantxu](https://github.com/ryantxu) +- **InfluxDB**: return a table rather than an error when timeseries is missing time. [#27320](https://github.com/grafana/grafana/pull/27320), [@ryantxu](https://github.com/ryantxu) +- **Instrumentation**: Adds instrumentation for outgoing datasource requests. [#27427](https://github.com/grafana/grafana/pull/27427), [@bergquist](https://github.com/bergquist) +- **Loki**: Add scopedVars support in legend formatting for repeated variables. [#27046](https://github.com/grafana/grafana/pull/27046), [@ivanahuckova](https://github.com/ivanahuckova) +- **Loki**: Re-introduce running of instant queries. [#27213](https://github.com/grafana/grafana/pull/27213), [@ivanahuckova](https://github.com/ivanahuckova) +- **Loki**: Support request cancellation properly (Uses new backendSrv.fetch Observable request API). [#27265](https://github.com/grafana/grafana/pull/27265), [@hugohaggmark](https://github.com/hugohaggmark) +- **MixedDatasource**: Shows retrieved data even if a data source fails. [#27024](https://github.com/grafana/grafana/pull/27024), [@hugohaggmark](https://github.com/hugohaggmark) +- **OAuth**: Handle DEFLATE compressed payloads in JWT for Generic OAuth. [#26969](https://github.com/grafana/grafana/pull/26969), [@billoley](https://github.com/billoley) +- **OAuth**: Increase state cookie max age. [#27258](https://github.com/grafana/grafana/pull/27258), [@bergquist](https://github.com/bergquist) +- **Orgs**: Remove org deprecation notice as we have decided to preserve multi-org support. [#26853](https://github.com/grafana/grafana/pull/26853), [@torkelo](https://github.com/torkelo) +- **PanelInspector**: Adds a Raw display mode but defaults to Formatted display mode. [#27306](https://github.com/grafana/grafana/pull/27306), [@hugohaggmark](https://github.com/hugohaggmark) +- **Postgres**: Support Unix socket for host. [#25778](https://github.com/grafana/grafana/pull/25778), [@aknuds1](https://github.com/aknuds1) +- **Prometheus**: Add scopedVars support in legend formatting for repeated variables. [#27047](https://github.com/grafana/grafana/pull/27047), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Support request cancellation properly (Uses new backendSrv.fetch Observable request API). [#27090](https://github.com/grafana/grafana/pull/27090), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: add $__rate_interval variable. [#26937](https://github.com/grafana/grafana/pull/26937), [@zoltanbedi](https://github.com/zoltanbedi) +- **Provisioning**: Validate that datasource access field equals to direct or proxy. [#26440](https://github.com/grafana/grafana/pull/26440), [@nabokihms](https://github.com/nabokihms) +- **RangeUtils**: migrate logic from kbn to grafana/data. [#27347](https://github.com/grafana/grafana/pull/27347), [@ryantxu](https://github.com/ryantxu) +- **Table**: Adds column filtering. [#27225](https://github.com/grafana/grafana/pull/27225), [@hugohaggmark](https://github.com/hugohaggmark) +- **Table**: Support showing numbers in strings with full original value. [#27097](https://github.com/grafana/grafana/pull/27097), [@torkelo](https://github.com/torkelo) +- **TablePanel**: Add support for basic gauge as a cell display mode. [#26595](https://github.com/grafana/grafana/pull/26595), [@jutley](https://github.com/jutley) +- **Transformations**: Group by and aggregate on multiple fields. [#25498](https://github.com/grafana/grafana/pull/25498), [@Totalus](https://github.com/Totalus) +- **Transformations**: enable transformations reorder. [#27197](https://github.com/grafana/grafana/pull/27197), [@dprokop](https://github.com/dprokop) +- **Units**: Allow re-scaling nanoseconds up to days. [#26458](https://github.com/grafana/grafana/pull/26458), [@kaydelaney](https://github.com/kaydelaney) +- **grafana-cli**: Add ability to read password from stdin to reset admin password. [#26016](https://github.com/grafana/grafana/pull/26016), [@nabokihms](https://github.com/nabokihms) +- **Reporting**: add branding options. (Enterprise) +- **Reporting**: allow setting custom timerange. (Enterprise) + +### Bug Fixes +- **Auth**: Fix signup workflow and UI when verify email is enabled. [#26263](https://github.com/grafana/grafana/pull/26263), [@KamalGalrani](https://github.com/KamalGalrani) +- **AzureMonitor**: Change filterDimensions property to match what is stored. [#27459](https://github.com/grafana/grafana/pull/27459), [@kylebrandt](https://github.com/kylebrandt) +- **Cloud Monitoring**: Fix missing title and text from cloud monitoring annotations. [#27187](https://github.com/grafana/grafana/pull/27187), [@atotto](https://github.com/atotto) +- **CloudWatch**: Fix error message returned from tag:GetResources. [#27205](https://github.com/grafana/grafana/pull/27205), [@kichik](https://github.com/kichik) +- **Cloudwatch**: Update AWS/MediaConnect metrics and dimensions. [#26093](https://github.com/grafana/grafana/pull/26093), [@papagian](https://github.com/papagian) +- **DashboardSettings**: Fixes auto refresh crash with space in interval. [#27438](https://github.com/grafana/grafana/pull/27438), [@hugohaggmark](https://github.com/hugohaggmark) +- **Elasticsearch**: Fix localized dates in index pattern. [#27351](https://github.com/grafana/grafana/pull/27351), [@domasx2](https://github.com/domasx2) +- **Elasticsearch**: Fix using multiple bucket script aggregations when only grouping by terms. [#24064](https://github.com/grafana/grafana/pull/24064), [@MarceloNunesAlves](https://github.com/MarceloNunesAlves) +- **Explore**: Expand template variables when redirecting from dashboard panel. [#27354](https://github.com/grafana/grafana/pull/27354), [@Elfo404](https://github.com/Elfo404) +- **FolderPicker**: Fixes not being able to create new folder. [#27092](https://github.com/grafana/grafana/pull/27092), [@hugohaggmark](https://github.com/hugohaggmark) +- **Graphite**: Show and hide query editor function popup on click. [#26923](https://github.com/grafana/grafana/pull/26923), [@ivanahuckova](https://github.com/ivanahuckova) +- **InfluxDB/Flux**: Fix for Alerts on InfluxDB Flux datasources only use the first series. [#27463](https://github.com/grafana/grafana/pull/27463), [@ryantxu](https://github.com/ryantxu) +- **Loki**: Send current time range when fetching labels and values. [#26622](https://github.com/grafana/grafana/pull/26622), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Add backslash escaping for template variables. [#26205](https://github.com/grafana/grafana/pull/26205), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Correctly format multi values variables in queries. [#26896](https://github.com/grafana/grafana/pull/26896), [@ivanahuckova](https://github.com/ivanahuckova) +- **Provisioning**: Add validation for missing organisations in datasource, dashboard, and notifier configurations. [#26601](https://github.com/grafana/grafana/pull/26601), [@nabokihms](https://github.com/nabokihms) +- **Rendering**: Fixed issue rendering text panel to image via image renderer plugin. [#27083](https://github.com/grafana/grafana/pull/27083), [@torkelo](https://github.com/torkelo) +- **Stats**: Use more efficient SQL and add timeouts. [#27390](https://github.com/grafana/grafana/pull/27390), [@sakjur](https://github.com/sakjur) +- **Table**: Support date unit formats on string values. [#26879](https://github.com/grafana/grafana/pull/26879), [@torkelo](https://github.com/torkelo) +- **Thresholds**: Fixed issue with thresholds in overrides not working after save and reload. [#27297](https://github.com/grafana/grafana/pull/27297), [@torkelo](https://github.com/torkelo) +- **Transformations**: Fixes outer join transformation when frames are missing field to join by. [#27453](https://github.com/grafana/grafana/pull/27453), [@hugohaggmark](https://github.com/hugohaggmark) +- **Transformations**: merge will properly handle empty frames and frames with multiple rows where values are overlapping. [#27362](https://github.com/grafana/grafana/pull/27362), [@mckn](https://github.com/mckn) +- **grafana-cli**: Fix installing of plugins missing directory entries in zip. [#26945](https://github.com/grafana/grafana/pull/26945), [@adrianlzt](https://github.com/adrianlzt) + +# 7.1.5 (2020-08-25) + +### Features / Enhancements +- **Stats**: Stop counting the same user multiple times. [#26777](https://github.com/grafana/grafana/pull/26777), [@sakjur](https://github.com/sakjur) + +### Bug Fixes +- **Alerting**: remove LongToWide call in alerting. [#27140](https://github.com/grafana/grafana/pull/27140), [@kylebrandt](https://github.com/kylebrandt) +- **AzureMonitor**: fix panic introduced in 7.1.4 when unit was unspecified and alias was used. [#27113](https://github.com/grafana/grafana/pull/27113), [@kylebrandt](https://github.com/kylebrandt) +- **Variables**: Fixes issue with All variable not being resolved. [#27151](https://github.com/grafana/grafana/pull/27151), [@hugohaggmark](https://github.com/hugohaggmark) + +# 7.1.4 (2020-08-20) + +### Features / Enhancements +- **Azure App Insights Alert error - tsdb.HandleRequest() failed to convert dataframe "" to tsdb.TimeSeriesSlice**. [#26897](https://github.com/grafana/grafana/issues/26897) +- **AzureMonitor**: map more units. [#26990](https://github.com/grafana/grafana/pull/26990), [@kylebrandt](https://github.com/kylebrandt) +- **Azuremonitor**: do not set unit if literal "Unspecified". [#26839](https://github.com/grafana/grafana/pull/26839), [@kylebrandt](https://github.com/kylebrandt) +- **Dataframe/Alerting**: to tsdb.TimeSeriesSlice - accept "empty" time series. [#26903](https://github.com/grafana/grafana/pull/26903), [@kylebrandt](https://github.com/kylebrandt) +- **Field overrides**: Filter by field name using regex. [#27070](https://github.com/grafana/grafana/pull/27070), [@dprokop](https://github.com/dprokop) +- **Overrides**: expose byType matcher UI. [#27056](https://github.com/grafana/grafana/pull/27056), [@ryantxu](https://github.com/ryantxu) + +### Bug Fixes +- **CloudWatch**: Add FreeStorageCapacity metric. [#26503](https://github.com/grafana/grafana/pull/26503), [@waqark3389](https://github.com/waqark3389) +- **CloudWatch**: Fix sorting of metrics results. [#26835](https://github.com/grafana/grafana/pull/26835), [@aknuds1](https://github.com/aknuds1) +- **Cloudwatch**: Add FileSystemId as a dimension key for the AWS/FSx namespace. [#26662](https://github.com/grafana/grafana/pull/26662), [@waqark3389](https://github.com/waqark3389) +- **InfluxDB**: Update Flux placeholder URL with respect to latest Go client. [#27086](https://github.com/grafana/grafana/pull/27086), [@aknuds1](https://github.com/aknuds1) +- **InfluxDB**: Upgrade Go client, use data source HTTP client. [#27012](https://github.com/grafana/grafana/pull/27012), [@aknuds1](https://github.com/aknuds1) +- **Proxy**: Fix updating refresh token in OAuth pass-thru. [#26885](https://github.com/grafana/grafana/pull/26885), [@seanlaff](https://github.com/seanlaff) +- **Templating**: Fixes so texts show in picker not the values. [#27002](https://github.com/grafana/grafana/pull/27002), [@hugohaggmark](https://github.com/hugohaggmark) + +# 7.1.3 (2020-08-06) + +### Bug Fixes + - **Templating**: Templating: Fix undefined result when using raw interpolation format [#26818](https://github.com/grafana/grafana/pull/26818) + +# 7.1.2 (2020-08-05) + +### Features / Enhancements + - **Explore**: Don't run queries on datasource change. [#26033](https://github.com/grafana/grafana/pull/26033), [@davkal](https://github.com/davkal) + - **TemplateSrv**: Formatting options for ${__from} and ${__to}, unix seconds epoch, ISO 8601/RFC 3339. [#26466](https://github.com/grafana/grafana/pull/26466), [@torkelo](https://github.com/torkelo) + - **Toolkit/Plugin**: throw an Error instead of a string. [#26618](https://github.com/grafana/grafana/pull/26618), [@leventebalogh](https://github.com/leventebalogh) + + ### Bug Fixes + - **Dashbard**: Fix refresh interval settings to allow setting it to equal min_refresh_interval. [#26615](https://github.com/grafana/grafana/pull/26615), [@torkelo](https://github.com/torkelo) + - **Flux**: Ensure connections to InfluxDB are closed. [#26735](https://github.com/grafana/grafana/pull/26735), [@sneddrs](https://github.com/sneddrs) + - **Query history**: Fix search filtering if null value. [#26768](https://github.com/grafana/grafana/pull/26768), [@ivanahuckova](https://github.com/ivanahuckova) + - **QueryOptions**: Fix not being able to change cache timeout setting. [#26614](https://github.com/grafana/grafana/pull/26614), [@torkelo](https://github.com/torkelo) + - **StatPanel**: Fix stat panel display name not showing when explicitly set. [#26616](https://github.com/grafana/grafana/pull/26616), [@torkelo](https://github.com/torkelo) + - **Templating**: Fixed access to system variables like __dashboard, __user & __org during dashboard load & variable queries. [#26637](https://github.com/grafana/grafana/pull/26637), [@torkelo](https://github.com/torkelo) + - **TextPanel**: Fix content overflowing panel boundaries. [#26612](https://github.com/grafana/grafana/pull/26612), [@torkelo](https://github.com/torkelo) + - **TimePicker**: Fix position and responsive behavior. [#26570](https://github.com/grafana/grafana/pull/26570), [@torkelo](https://github.com/torkelo) + - **TimePicker**: Fixes app crash when changing custom range to nothing. [#26775](https://github.com/grafana/grafana/pull/26775), [@hugohaggmark](https://github.com/hugohaggmark) + - **Units**: Remove duplicate SI prefix from mSv and µSv. [#26598](https://github.com/grafana/grafana/pull/26598), [@tofurky](https://github.com/tofurky) + +# 7.1.1 (2020-07-24) + +### Features / Enhancements +- **Graph**: Support setting field unit & override data source (automatic) unit. [#26529](https://github.com/grafana/grafana/pull/26529), [@ryantxu](https://github.com/ryantxu) +- **Tracing**: Add errorIconColor prop to TraceSpanData. [#26509](https://github.com/grafana/grafana/pull/26509), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug Fixes +- **Branding**: Fix login app title. [#26425](https://github.com/grafana/grafana/pull/26425), [@benrubson](https://github.com/benrubson) +- **Bring back scripts evaluation in TextPanel**. [#26413](https://github.com/grafana/grafana/pull/26413), [@dprokop](https://github.com/dprokop) +- **Dashboard**: Fix empty panels after scrolling on Safari/iOS. [#26495](https://github.com/grafana/grafana/pull/26495), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fix for viewer can enter panel edit mode by modifying url (but cannot not save anything). [#26556](https://github.com/grafana/grafana/pull/26556), [@torkelo](https://github.com/torkelo) +- **Elasticsearch**: Fix displaying of bucket script input. [#26552](https://github.com/grafana/grafana/pull/26552), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: parse queryType from explore url. [#26349](https://github.com/grafana/grafana/pull/26349), [@zoltanbedi](https://github.com/zoltanbedi) +- **Tracing**: upstream fix for hovering on log lines. [#26426](https://github.com/grafana/grafana/pull/26426), [@zoltanbedi](https://github.com/zoltanbedi) + +# 7.1.0 (2020-07-16) + +### Features / Enhancements +- **Backend**: Use latest go plugin sdk (0.74.0) to sort wide frames. [#26207](https://github.com/grafana/grafana/pull/26207), [@kylebrandt](https://github.com/kylebrandt) +- **Elasticsearch**: Create Raw Doc metric to render raw JSON docs in columns in the new table panel. [#26233](https://github.com/grafana/grafana/pull/26233), [@ivanahuckova](https://github.com/ivanahuckova) +- **PluginsListPage**: More plugins button should open in new window. [#26305](https://github.com/grafana/grafana/pull/26305), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug Fixes +- **AdminUsers**: Reset page to zero on query change. [#26293](https://github.com/grafana/grafana/pull/26293), [@hshoff](https://github.com/hshoff) +- **CloudWatch Logs**: Fixes grouping of results by numeric field. [#26298](https://github.com/grafana/grafana/pull/26298), [@kaydelaney](https://github.com/kaydelaney) +- **DashboardLinks**: Do not over-query search endpoint. [#26311](https://github.com/grafana/grafana/pull/26311), [@torkelo](https://github.com/torkelo) +- **Docker**: Make sure to create default plugin provisioning directory. [#26017](https://github.com/grafana/grafana/pull/26017), [@marefr](https://github.com/marefr) +- **Elastic**: Fix error "e.buckets[Symbol.iterator] is not a function" when using filter. [#26217](https://github.com/grafana/grafana/pull/26217), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/Loki**: Escape \ in labels for show context queries. [#26116](https://github.com/grafana/grafana/pull/26116), [@ivanahuckova](https://github.com/ivanahuckova) +- **Jaeger/Zipkin**: URL-encode service names and trace ids for API calls. [#26115](https://github.com/grafana/grafana/pull/26115), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Fix prom links in mixed mode. [#26244](https://github.com/grafana/grafana/pull/26244), [@zoltanbedi](https://github.com/zoltanbedi) +- **Provisioning**: Fix bug when provision app plugins using Enterprise edition. [#26340](https://github.com/grafana/grafana/pull/26340), [@marefr](https://github.com/marefr) +- **Sign In** Use correct url for the Sign In button. [#26239](https://github.com/grafana/grafana/pull/26239), [@dprokop](https://github.com/dprokop) + +# 7.1.0-beta3 (2020-07-13) + +### Features / Enhancements +- **Explore**: Unification of logs/metrics/traces user interface. [#25890](https://github.com/grafana/grafana/pull/25890), [@aocenas](https://github.com/aocenas) +- **Graph panel**: Move Stacking and null values before Hover tooltip options (#26035). [#26037](https://github.com/grafana/grafana/pull/26037), [@jsoref](https://github.com/jsoref) +- **LDAP**: Get all groups for all group base search DNs. [#25825](https://github.com/grafana/grafana/pull/25825), [@Annegies](https://github.com/Annegies) +- **Table**: JSON Cell should try to convert strings to JSON. [#26024](https://github.com/grafana/grafana/pull/26024), [@ryantxu](https://github.com/ryantxu) +- **Transform**: adding missing "table"-transform and "series to rows"-transform to Grafana v7-transforms. [#26042](https://github.com/grafana/grafana/pull/26042), [@mckn](https://github.com/mckn) + +### Bug Fixes +- **AdminUsersTable**: Fix width issues. [#26019](https://github.com/grafana/grafana/pull/26019), [@tskarhed](https://github.com/tskarhed) +- **BarGauge**: Fix space bug in single series mode. [#26176](https://github.com/grafana/grafana/pull/26176), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Allow removing min refresh interval from refresh options (5s or other). [#26150](https://github.com/grafana/grafana/pull/26150), [@torkelo](https://github.com/torkelo) +- **DataLinks**: Fixed interpolation of repeated variables used in Graph data links. [#26147](https://github.com/grafana/grafana/pull/26147), [@torkelo](https://github.com/torkelo) +- **Do not break dashboard settings UI when time intervals end with trailing comma**. [#26126](https://github.com/grafana/grafana/pull/26126), [@dprokop](https://github.com/dprokop) +- **Elastic**: Display correct log message based on selected log field. [#26020](https://github.com/grafana/grafana/pull/26020), [@ivanahuckova](https://github.com/ivanahuckova) +- **InfluxDB**: Fixed new group by dropdown now showing after first use. [#26031](https://github.com/grafana/grafana/pull/26031), [@torkelo](https://github.com/torkelo) +- **StatPanel**: Fixes issue with name showing for single series / field results. [#26070](https://github.com/grafana/grafana/pull/26070), [@torkelo](https://github.com/torkelo) +- **Templating**: Fix recursive loop of template variable queries when changing ad-hoc-variable. [#26191](https://github.com/grafana/grafana/pull/26191), [@torkelo](https://github.com/torkelo) + +# 7.0.6 (2020-07-09) + +### Bug fixes + +- **Templating**: Fixed recursive queries triggered when switching dashboard settings view [#26137](https://github.com/grafana/grafana/pull/26137) +- **Templating**: Fix recursive loop of template variable queries when changing ad-hoc-variable [#26191](https://github.com/grafana/grafana/pull/26191) +- **Auth**: Add support for forcing authentication in anonymous mode and modify SignIn to use it instead of redirect [#25567](https://github.com/grafana/grafana/pull/25567) +- **Auth**: Fix POST request failures with anonymous access [#26049](https://github.com/grafana/grafana/pull/26049) + +# 7.1.0-beta 2 (2020-07-02) + +### Features / Enhancements +- **Loki**: Allow aliasing Loki queries in dashboard. [#25706](https://github.com/grafana/grafana/pull/25706), [@bastjan](https://github.com/bastjan) + +### Bug Fixes +- **Explore**: Fix href when jumping from Explore to Add data source. [#25991](https://github.com/grafana/grafana/pull/25991), [@ivanahuckova](https://github.com/ivanahuckova) +- **Fix**: Build-in plugins failed to load in windows. [#25982](https://github.com/grafana/grafana/pull/25982), [@papagian](https://github.com/papagian) + +# 7.1.0-beta 1 (2020-07-01) + +### Features / Enhancements +- **Alerting**: Adds support for multiple URLs in Alertmanager notifier. [#24196](https://github.com/grafana/grafana/pull/24196), [@alistarle](https://github.com/alistarle) +- **Alerting**: updating the victorops alerter to handle the no_data alert type. [#23761](https://github.com/grafana/grafana/pull/23761), [@rrusso1982](https://github.com/rrusso1982) +- **Azure**: Application Insights metrics to Frame and support multiple query dimensions. [#25849](https://github.com/grafana/grafana/pull/25849), [@kylebrandt](https://github.com/kylebrandt) +- **Azure**: Multiple dimension support for Azure Monitor Service. [#25947](https://github.com/grafana/grafana/pull/25947), [@kylebrandt](https://github.com/kylebrandt) +- **Azure**: Split Insights into two services. [#25410](https://github.com/grafana/grafana/pull/25410), [@kylebrandt](https://github.com/kylebrandt) +- **Backend plugins**: Refactor to allow shared contract between core and external backend plugins. [#25472](https://github.com/grafana/grafana/pull/25472), [@marefr](https://github.com/marefr) +- **Branding**: Use AppTitle as document title. [#25271](https://github.com/grafana/grafana/pull/25271), [@benrubson](https://github.com/benrubson) +- **Chore**: upgrade to typescript 3.9.3. [#25154](https://github.com/grafana/grafana/pull/25154), [@ryantxu](https://github.com/ryantxu) +- **CloudWatch**: Add Route53 DNSQueries metric and dimension. [#25125](https://github.com/grafana/grafana/pull/25125), [@erkolson](https://github.com/erkolson) +- **CloudWatch**: Added AWS DataSync metrics and dimensions. [#25054](https://github.com/grafana/grafana/pull/25054), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS MediaStore metrics and dimensions. [#25492](https://github.com/grafana/grafana/pull/25492), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS RoboMaker metrics and dimensions. [#25090](https://github.com/grafana/grafana/pull/25090), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS SDKMetrics metrics and dimensions. [#25150](https://github.com/grafana/grafana/pull/25150), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS ServiceCatalog metrics and dimensions. [#25812](https://github.com/grafana/grafana/pull/25812), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS WAFV2 metrics. [#24048](https://github.com/grafana/grafana/pull/24048), [@mikkokupsu](https://github.com/mikkokupsu) +- **Dashboards**: Make path to default dashboard configurable. [#25595](https://github.com/grafana/grafana/pull/25595), [@bergquist](https://github.com/bergquist) +- **Elastic**: Internal data links. [#25942](https://github.com/grafana/grafana/pull/25942), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Add support for template variable in date histogram min_doc_count. [#21064](https://github.com/grafana/grafana/pull/21064), [@faxm0dem](https://github.com/faxm0dem) +- **Elasticsearch**: Adds cumulative sum aggregation support. [#24820](https://github.com/grafana/grafana/pull/24820), [@retzkek](https://github.com/retzkek) +- **Elasticsearch**: Support using a variable for histogram and terms min doc count. [#25392](https://github.com/grafana/grafana/pull/25392), [@marefr](https://github.com/marefr) +- **Explore/Loki**: Show results of instant queries only in table and time series only in graph. [#25845](https://github.com/grafana/grafana/pull/25845), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Remove legend formatting when switching from panel to Explore. [#25848](https://github.com/grafana/grafana/pull/25848), [@ivanahuckova](https://github.com/ivanahuckova) +- **Footer**: Add back footer to login page. [#25656](https://github.com/grafana/grafana/pull/25656), [@torkelo](https://github.com/torkelo) +- **ForgottenPassword**: Move view to login screen. [#25366](https://github.com/grafana/grafana/pull/25366), [@tskarhed](https://github.com/tskarhed) +- **Gauge**: Hide orientation option in panel options. [#25511](https://github.com/grafana/grafana/pull/25511), [@torkelo](https://github.com/torkelo) +- **Grafana-UI**: Add FileUpload. [#25835](https://github.com/grafana/grafana/pull/25835), [@Clarity-89](https://github.com/Clarity-89) +- **GraphPanel**: Make legend values clickable series toggles. [#25581](https://github.com/grafana/grafana/pull/25581), [@hshoff](https://github.com/hshoff) +- **Influx**: Support flux in the influx datasource. [#25308](https://github.com/grafana/grafana/pull/25308), [@ryantxu](https://github.com/ryantxu) +- **Migration**: Select org. [#24739](https://github.com/grafana/grafana/pull/24739), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Settings forms. [#24741](https://github.com/grafana/grafana/pull/24741), [@tskarhed](https://github.com/tskarhed) +- **Panel Inspect**: use Monaco editor for json display. [#25251](https://github.com/grafana/grafana/pull/25251), [@ryantxu](https://github.com/ryantxu) +- **Panel edit**: Clicking twice on a visualization closes the VizPicker. [#25739](https://github.com/grafana/grafana/pull/25739), [@peterholmberg](https://github.com/peterholmberg) +- **PanelInspect**: Update UI for Data display options. [#25478](https://github.com/grafana/grafana/pull/25478), [@tskarhed](https://github.com/tskarhed) +- **Plugins**: move jaeger trace type to grafana data. [#25403](https://github.com/grafana/grafana/pull/25403), [@zoltanbedi](https://github.com/zoltanbedi) +- **Provisioning**: Adds support for enabling app plugins. [#25649](https://github.com/grafana/grafana/pull/25649), [@marefr](https://github.com/marefr) +- **Provisioning**: Use folders structure from the file system to create desired folders in dashboard provisioning. [#23117](https://github.com/grafana/grafana/pull/23117), [@nabokihms](https://github.com/nabokihms) +- **Query history**: Add keyboard shortcut support for commenting. [#24736](https://github.com/grafana/grafana/pull/24736), [@ivanahuckova](https://github.com/ivanahuckova) +- **Query history**: Add search for query history and starred queries. [#25747](https://github.com/grafana/grafana/pull/25747), [@ivanahuckova](https://github.com/ivanahuckova) +- **Rich history**: Updates for default settings and starred queries deletion. [#25732](https://github.com/grafana/grafana/pull/25732), [@ivanahuckova](https://github.com/ivanahuckova) +- **Search**: support URL query params. [#25541](https://github.com/grafana/grafana/pull/25541), [@Clarity-89](https://github.com/Clarity-89) +- **Stackdriver**: Deep linking from Grafana panels to the Metrics Explorer. [#25858](https://github.com/grafana/grafana/pull/25858), [@papagian](https://github.com/papagian) +- **Stackdriver**: Rename Stackdriver to Google Cloud Monitoring. [#25807](https://github.com/grafana/grafana/pull/25807), [@papagian](https://github.com/papagian) +- **StatPanel**: Option showing name instead of value and more. [#25676](https://github.com/grafana/grafana/pull/25676), [@torkelo](https://github.com/torkelo) +- **Switch**: Deprecate checked prop in favor of value. [#25862](https://github.com/grafana/grafana/pull/25862), [@tskarhed](https://github.com/tskarhed) +- **Tab**: Make active tab clickable and add hyperlink functionality. [#25546](https://github.com/grafana/grafana/pull/25546), [@tskarhed](https://github.com/tskarhed) +- **Table**: Adds adhoc filtering. [#25467](https://github.com/grafana/grafana/pull/25467), [@hugohaggmark](https://github.com/hugohaggmark) +- **Teams**: Add index for permission check. [#25736](https://github.com/grafana/grafana/pull/25736), [@sakjur](https://github.com/sakjur) +- **Template variable filters**: Hide overflowing text. [#25801](https://github.com/grafana/grafana/pull/25801), [@tskarhed](https://github.com/tskarhed) +- **Templating**: Add bult in __user {name, id, login, email} variable to templating system. [#23378](https://github.com/grafana/grafana/pull/23378), [@aidanmountford](https://github.com/aidanmountford) +- **Templating**: removes old Angular variable system and featureToggle. [#24779](https://github.com/grafana/grafana/pull/24779), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Adds proper editor for markdown and html. [#25618](https://github.com/grafana/grafana/pull/25618), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Removes Angular Text Panel. [#25504](https://github.com/grafana/grafana/pull/25504), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Removes text mode. [#25589](https://github.com/grafana/grafana/pull/25589), [@hugohaggmark](https://github.com/hugohaggmark) +- **TimeZone**: unify the time zone pickers to one that can rule them all. [#24803](https://github.com/grafana/grafana/pull/24803), [@mckn](https://github.com/mckn) +- **Transform**: added merge transform that will merge multiple series/tables into one table. [#25490](https://github.com/grafana/grafana/pull/25490), [@mckn](https://github.com/mckn) +- **Units**: add base-pascals and rotational speed units. [#22879](https://github.com/grafana/grafana/pull/22879), [@sakjur](https://github.com/sakjur) +- **Units**: add new unit for duration, it is optimized for displaying days, hours, minutes and seconds. [#24175](https://github.com/grafana/grafana/pull/24175), [@pabigot](https://github.com/pabigot) +- **Variables**: enables cancel for slow query variables queries. [#24430](https://github.com/grafana/grafana/pull/24430), [@hugohaggmark](https://github.com/hugohaggmark) +- **switches default value for security settings**. [#25175](https://github.com/grafana/grafana/pull/25175), [@bergquist](https://github.com/bergquist) +- **Reporting:** add monthly schedule option. (Enterprise) + +### Bug Fixes +- **DatatLinks**: Fix open in new tab state mismatch. [#25826](https://github.com/grafana/grafana/pull/25826), [@tskarhed](https://github.com/tskarhed) +- **Explore/Loki**: Fix field type in table for instant queries. [#25907](https://github.com/grafana/grafana/pull/25907), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/Loki**: Fix scrolling of context when leaving context window. [#25838](https://github.com/grafana/grafana/pull/25838), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/SQL data sources**: Show correctly interpolated queries. [#25110](https://github.com/grafana/grafana/pull/25110), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/Tooltip**: Fix label value in tooltip. [#25940](https://github.com/grafana/grafana/pull/25940), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix query editors on mobile. [#25148](https://github.com/grafana/grafana/pull/25148), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: adds an ability to exit log row context with ESC key. [#24205](https://github.com/grafana/grafana/pull/24205), [@Estrax](https://github.com/Estrax) +- **Fix**: Value mappings match against string values. [#25929](https://github.com/grafana/grafana/pull/25929), [@peterholmberg](https://github.com/peterholmberg) +- **GraphPanel**: Fix annotations overflowing panels. [#25606](https://github.com/grafana/grafana/pull/25606), [@hshoff](https://github.com/hshoff) +- **Instrumentation**: Fix setting Jaeger tracing address through Grafana config. [#25768](https://github.com/grafana/grafana/pull/25768), [@marefr](https://github.com/marefr) +- **Prometheus**: Fix performance issue in processing of histogram labels. [#25813](https://github.com/grafana/grafana/pull/25813), [@bsherrod](https://github.com/bsherrod) +- **Provisioning**: Makes file the default dashboard provisioner type. [#24856](https://github.com/grafana/grafana/pull/24856), [@bergquist](https://github.com/bergquist) +- **Templating**: fixes variables not being interpolated after dashboard refresh. [#25698](https://github.com/grafana/grafana/pull/25698), [@hugohaggmark](https://github.com/hugohaggmark) +- **Units**: Custom unit suffix and docs for custom units. [#25710](https://github.com/grafana/grafana/pull/25710), [@torkelo](https://github.com/torkelo) +- **ValueFormats**: Fix byte-format data rates. [#25424](https://github.com/grafana/grafana/pull/25424), [@mueslo](https://github.com/mueslo) +- **Variables**: Fixes maximum call stack bug for empty value. [#25503](https://github.com/grafana/grafana/pull/25503), [@hugohaggmark](https://github.com/hugohaggmark) + +### Security fixes +- **Graph**: Fix XSS vulnerability with series overrides [#25401](https://github.com/grafana/grafana/pull/25401). Thanks to Rotem Reiss for reporting this. + +# 7.0.5 (2020-06-30) + +### Bug Fixes + +- **Datasource**: Make sure data proxy timeout applies to HTTP client. [#25865](https://github.com/grafana/grafana/pull/25865), [@marefr](https://github.com/marefr) +- **Graphite**: Fix tag value dropdowns not showing in query editor. [#25889](https://github.com/grafana/grafana/pull/25889), [@torkelo](https://github.com/torkelo) + +# 7.0.4 (2020-06-25) + +### Features / Enhancements + +- **Dashboard**: Redirects for old (pre 7.0) edit & view panel urls. [#25653](https://github.com/grafana/grafana/pull/25653), [@torkelo](https://github.com/torkelo) +- **Stackdriver**: Use default project name if project name isn't set on the query. [#25413](https://github.com/grafana/grafana/pull/25413), [@alexashley](https://github.com/alexashley) +- **TablePanel**: Sort numbers correctly. [#25421](https://github.com/grafana/grafana/pull/25421), [@speakyourcode](https://github.com/speakyourcode) +- **Update Bitcoin currency to use proper symbol, add mBTC and μBTC**. [#24182](https://github.com/grafana/grafana/pull/24182), [@overcookedpanda](https://github.com/overcookedpanda) +- **Variables**: Links that update variables on current dashboard does not trigger refresh / update. [#25192](https://github.com/grafana/grafana/pull/25192), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **Azure Monitor**: fixes undefined is not iterable. [#25586](https://github.com/grafana/grafana/pull/25586), [@hugohaggmark](https://github.com/hugohaggmark) +- **Datasources**: Handle URL parsing error. [#25742](https://github.com/grafana/grafana/pull/25742), [@marefr](https://github.com/marefr) +- **InfluxDB**: Fix invalid memory address or nil pointer dereference when schema is missing in URL. [#25565](https://github.com/grafana/grafana/pull/25565), [@marefr](https://github.com/marefr) +- **Security**: Use Header.Set and Header.Del for X-Grafana-User header. [#25495](https://github.com/grafana/grafana/pull/25495), [@beardhatcode](https://github.com/beardhatcode) +- **Stackdriver**: Fix creating Label Values datasource query variable. [#25633](https://github.com/grafana/grafana/pull/25633), [@papagian](https://github.com/papagian) +- **Table**: Support custom date formats via custom unit. [#25195](https://github.com/grafana/grafana/pull/25195), [@torkelo](https://github.com/torkelo) +- **Templating**: Fixes query variable with \${\_\_searchFilter} value selection not causing refresh & url update. [#25770](https://github.com/grafana/grafana/pull/25770), [@torkelo](https://github.com/torkelo) + +# 7.0.3 (2020-06-03) + +### Features / Enhancements + +- **Stats**: include all fields. [#24829](https://github.com/grafana/grafana/pull/24829), [@ryantxu](https://github.com/ryantxu) +- **Variables**: change VariableEditorList row action Icon to IconButton. [#25217](https://github.com/grafana/grafana/pull/25217), [@hshoff](https://github.com/hshoff) + +### Bug Fixes + +- **Cloudwatch**: Fix dimensions of DDoSProtection. [#25317](https://github.com/grafana/grafana/pull/25317), [@papagian](https://github.com/papagian) +- **Configuration**: Fix env var override of sections containing hyphen. [#25178](https://github.com/grafana/grafana/pull/25178), [@marefr](https://github.com/marefr) +- **Dashboard**: Get panels in collapsed rows. [#25079](https://github.com/grafana/grafana/pull/25079), [@peterholmberg](https://github.com/peterholmberg) +- **Do not show alerts tab when alerting is disabled**. [#25285](https://github.com/grafana/grafana/pull/25285), [@dprokop](https://github.com/dprokop) +- **Jaeger**: fixes cascader option label duration value. [#25129](https://github.com/grafana/grafana/pull/25129), [@Estrax](https://github.com/Estrax) +- **Transformations**: Fixed Transform tab crash & no update after adding first transform. [#25152](https://github.com/grafana/grafana/pull/25152), [@torkelo](https://github.com/torkelo) + +# 7.0.2 (2020-06-03) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2020/06/03/grafana-6.7.4-and-7.0.2-released-with-important-security-fix/) + +# 7.0.1 (2020-05-26) + +### Features / Enhancements + +- **Datasource/CloudWatch**: Makes CloudWatch Logs query history more readable. [#24795](https://github.com/grafana/grafana/pull/24795), [@kaydelaney](https://github.com/kaydelaney) +- **Download CSV**: Add date and time formatting. [#24992](https://github.com/grafana/grafana/pull/24992), [@ryantxu](https://github.com/ryantxu) +- **Table**: Make last cell value visible when right aligned. [#24921](https://github.com/grafana/grafana/pull/24921), [@peterholmberg](https://github.com/peterholmberg) +- **TablePanel**: Adding sort order persistance. [#24705](https://github.com/grafana/grafana/pull/24705), [@torkelo](https://github.com/torkelo) +- **Transformations**: Display correct field name when using reduce transformation. [#25068](https://github.com/grafana/grafana/pull/25068), [@peterholmberg](https://github.com/peterholmberg) +- **Transformations**: Allow custom number input for binary operations. [#24752](https://github.com/grafana/grafana/pull/24752), [@ryantxu](https://github.com/ryantxu) + +### Bug Fixes + +- **Cloudwatch**: Fix AWS WAF and AWS DDoSProtection metrics. [#25071](https://github.com/grafana/grafana/pull/25071), [@papagian](https://github.com/papagian) +- **Dashboard/Links**: Fixes dashboard links by tags not working. [#24773](https://github.com/grafana/grafana/pull/24773), [@KamalGalrani](https://github.com/KamalGalrani) +- **Dashboard/Links**: Fixes open in new window for dashboard link. [#24772](https://github.com/grafana/grafana/pull/24772), [@KamalGalrani](https://github.com/KamalGalrani) +- **Dashboard/Links**: Variables are resolved and limits to 100. [#25076](https://github.com/grafana/grafana/pull/25076), [@hugohaggmark](https://github.com/hugohaggmark) +- **DataLinks**: Bring back variables interpolation in title. [#24970](https://github.com/grafana/grafana/pull/24970), [@dprokop](https://github.com/dprokop) +- **Datasource/CloudWatch**: Field suggestions no longer limited to prefix-only. [#24855](https://github.com/grafana/grafana/pull/24855), [@kaydelaney](https://github.com/kaydelaney) +- **Explore/Table**: Keep existing field types if possible. [#24944](https://github.com/grafana/grafana/pull/24944), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Fix wrap lines toggle for results of queries with filter expression. [#24915](https://github.com/grafana/grafana/pull/24915), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: fix undo in query editor. [#24797](https://github.com/grafana/grafana/pull/24797), [@zoltanbedi](https://github.com/zoltanbedi) +- **Explore**: fix word break in type head info. [#25014](https://github.com/grafana/grafana/pull/25014), [@zoltanbedi](https://github.com/zoltanbedi) +- **Graph**: Legend decimals now work as expected. [#24931](https://github.com/grafana/grafana/pull/24931), [@torkelo](https://github.com/torkelo) +- **LoginPage**: Fix hover color for service buttons. [#25009](https://github.com/grafana/grafana/pull/25009), [@tskarhed](https://github.com/tskarhed) +- **LogsPanel**: Fix scrollbar. [#24850](https://github.com/grafana/grafana/pull/24850), [@ivanahuckova](https://github.com/ivanahuckova) +- **MoveDashboard**: Fix for moving dashboard caused all variables to be lost. [#25005](https://github.com/grafana/grafana/pull/25005), [@torkelo](https://github.com/torkelo) +- **Organize transformer**: Use display name in field order comparer. [#24984](https://github.com/grafana/grafana/pull/24984), [@dprokop](https://github.com/dprokop) +- **Panel**: shows correct panel menu items in view mode. [#24912](https://github.com/grafana/grafana/pull/24912), [@hugohaggmark](https://github.com/hugohaggmark) +- **PanelEditor Fix missing labels and description if there is only single option in category**. [#24905](https://github.com/grafana/grafana/pull/24905), [@dprokop](https://github.com/dprokop) +- **PanelEditor**: Overrides name matcher still show all original field names even after Field default display name is specified. [#24933](https://github.com/grafana/grafana/pull/24933), [@torkelo](https://github.com/torkelo) +- **PanelInspector**: Makes sure Data display options are visible. [#24902](https://github.com/grafana/grafana/pull/24902), [@hugohaggmark](https://github.com/hugohaggmark) +- **PanelInspector**: Hides unsupported data display options for Panel type. [#24918](https://github.com/grafana/grafana/pull/24918), [@hugohaggmark](https://github.com/hugohaggmark) +- **PanelMenu**: Make menu disappear on button press. [#25015](https://github.com/grafana/grafana/pull/25015), [@tskarhed](https://github.com/tskarhed) +- **Postgres**: Fix add button. [#25087](https://github.com/grafana/grafana/pull/25087), [@phemmer](https://github.com/phemmer) +- **Prometheus**: Fix recording rules expansion. [#24977](https://github.com/grafana/grafana/pull/24977), [@ivanahuckova](https://github.com/ivanahuckova) +- **Stackdriver**: Fix creating Service Level Objectives (SLO) datasource query variable. [#25023](https://github.com/grafana/grafana/pull/25023), [@papagian](https://github.com/papagian) + +# 7.0.0 (2020-05-18) + +## Breaking changes + +- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). +- **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. +- **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. +- **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. +- **Backend plugins**: Grafana now requires backend plugins to be signed, otherwise Grafana will not load/start them. This is an additional security measure to make sure backend plugin binaries and files haven't been tampered with. Refer to [Upgrade Grafana](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v7-0) for more information. +- **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) + +**Deprecation warnings** + +- Scripted dashboards is now deprecated. The feature is not removed but will be in a future release. We hope to address the underlying requirement of dynamic dashboards in a different way. [#24059](https://github.com/grafana/grafana/issues/24059) +- The unofficial first version of backend plugins together with usage of [grafana/grafana-plugin-model](https://github.com/grafana/grafana-plugin-model) is now deprecated and support for that will be removed in a future release. Please refer to [backend plugins documentation](https://grafana.com/docs/grafana/latest/developers/plugins/backend/) for information about the new officially supported backend plugins. + +## 7.0 Feature highlights + +### Data transformations + +Not just visualizing data from anywhere, in Grafana 7 you can transform it too. By chaining a simple set of point and click transformations users will be able join, pivot, filter, re-name and calculate to get the results they need. Perfect for operations across queries or data sources missing essential data transformations. + +Data transformations will provide a common set of data operations that were previously duplicated as custom features in many panels or data sources but are now an integral part of the Grafana data processing pipeline and something all data sources and panels can take advantage of. + +In Grafana 7.0 we have a shared data model for both time series and table data that we call [DataFrame](https://github.com/grafana/grafana/blob/master/docs/sources/plugins/developing/dataframe.md). A DataFrame is like a table with columns but we refer to columns as fields. A time series is simply a DataFrame with two fields (time & value). + +**Transformations shipping in 7.0** + +- **Reduce**: Reduce many rows / data points to a single value +- **Filter by name**: Filter fields by name or regex +- **Filter by refId**: Filter by query letter +- **Organize fields**: Reorder, rename and hide fields. +- **Labels to fields**: Transform time series with labels into a table where labels get's converted to fields and the result is joined by time +- **Join by field**: Join many result sets (series) together using for example the time field. Useful for transforming time series into a table with a shared time column and where each series get it's own column. +- **Add field from calculation**: This is a powerful transformation that allows you perform many different types of math operations and add the result as a new field. Examples: + - Calculate the difference between two series or fields and add the result to a new field + - Multiply one field with another another and add the result to a new field + +### New panel edit experience + +In Grafana 7 we have redesigned the UI for editing panels. The first visible change is that we have separated panel display settings to a right hand side pane that you can collapse or expand depending on what your focus is on. With this change we are also introducing our new unified option model & UI for defining data configuration and display options. This unified data configuration system powers a consistent UI for setting data options across visualizations as well as making all data display settings data driven and overridable. + +This new option architecture and UI will make all panels have a consistent set of options and behaviors for attributes like `unit`, `min`, `max`, `thresholds`, `links`, `decimals`. Not only that but all these options will share a consistent UI for specifying override rules and is extensible for custom panel specific options. + +We have yet to migrate all core panels to this new architecture so in 7.0 there will sadly be some big inconsistencies in the UI between panels. Hopefully this will be fixed soon in future releases as we update all the core panels and help the community update the community panel plugins. + +### New table panel + +Grafana 7.0 comes with a new table panel (and deprecates the old one). This new table panel supports horizontal scrolling and column resize. Paired with the new `Organize fields` transformation detailed above you can reorder, hide & rename columns. This new panel also supports new cell display modes, like showing a bar gauge inside a cell. + +### Panel inspector + +The panel inspector is a feature that every panel will support, including internal as well as external community plugins. In this new panel inspector, you can view the raw data in a table format, apply some pre-defined transformations, and download as CSV. You can find the **Inspect** setting in the panel menu. Use the keyboard shortcut `i` when hovering over a panel to get the panel inspector to appear. + +### Improved time zone support + +Starting in version 7.0, you can override the time zone used to display date and time values in a dashboard. + +With this feature, you can specify the local time zone of the service or system that you are monitoring. This can be helpful when monitoring a system or service that operates across several time zones. + +We have also extended the time zone options so you can select any of the standard [ISO 8601 time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +### Features / Enhancements + +- **Azure Monitor**: Deep linking from Log Analytic queries to the Azure Portal. [#24417](https://github.com/grafana/grafana/pull/24417), [@daniellee](https://github.com/daniellee) +- **Backend plugins**: Log deprecation warning when using the unofficial first version of backend plugins. [#24675](https://github.com/grafana/grafana/pull/24675), [@marefr](https://github.com/marefr) +- **CloudWatch/Logs**: Add data links to CloudWatch logs for deep linking to AWS. [#24334](https://github.com/grafana/grafana/pull/24334), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch**: Unify look of query mode select between dashboard and explore. [#24648](https://github.com/grafana/grafana/pull/24648), [@aocenas](https://github.com/aocenas) +- **Docker**: Adds tzdata package to Ubuntu image. [#24422](https://github.com/grafana/grafana/pull/24422), [@xlson](https://github.com/xlson) +- **Editor**: New line on Enter, run query on Shift+Enter. [#24654](https://github.com/grafana/grafana/pull/24654), [@davkal](https://github.com/davkal) +- **Loki**: Allow multiple derived fields with the same name. [#24437](https://github.com/grafana/grafana/pull/24437), [@aocenas](https://github.com/aocenas) +- **Orgs**: Add future deprecation notice. [#24502](https://github.com/grafana/grafana/pull/24502), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **@grafana/toolkit**: Use process.cwd() instead of PWD to get directory. [#24677](https://github.com/grafana/grafana/pull/24677), [@zoltanbedi](https://github.com/zoltanbedi) +- **Admin**: Makes long settings values line break in settings page. [#24559](https://github.com/grafana/grafana/pull/24559), [@hugohaggmark](https://github.com/hugohaggmark) +- **Azure Monitor**: Fix failure when using table join in Log Analytics queries. [#24528](https://github.com/grafana/grafana/pull/24528), [@daniellee](https://github.com/daniellee) +- **CloudWatch/Logs**: Add error message when log groups are not selected. [#24361](https://github.com/grafana/grafana/pull/24361), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Allows a user to search for log groups that aren't there initially. [#24695](https://github.com/grafana/grafana/pull/24695), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Correctly interpolate variables in logs queries. [#24619](https://github.com/grafana/grafana/pull/24619), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Fix autocomplete after by keyword. [#24644](https://github.com/grafana/grafana/pull/24644), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix field autocomplete suggestions inside function. [#24406](https://github.com/grafana/grafana/pull/24406), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix fields not being refetched when log group changed. [#24529](https://github.com/grafana/grafana/pull/24529), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix panic on multiple aggregations queries. [#24683](https://github.com/grafana/grafana/pull/24683), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix query error when results were sparse. [#24702](https://github.com/grafana/grafana/pull/24702), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix suggestion for already inserted field. [#24581](https://github.com/grafana/grafana/pull/24581), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix suggestions of fields after comma. [#24520](https://github.com/grafana/grafana/pull/24520), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fixes various autocomplete issues. [#24583](https://github.com/grafana/grafana/pull/24583), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Handle errors that are not awserr.Error instances. [#24641](https://github.com/grafana/grafana/pull/24641), [@aknuds1](https://github.com/aknuds1) +- **CloudWatch/Logs**: Handle invalidation of log groups when switching data source. [#24703](https://github.com/grafana/grafana/pull/24703), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Make stats hint show consistently. [#24392](https://github.com/grafana/grafana/pull/24392), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Prevents hidden data frame fields from displaying in tables. [#24580](https://github.com/grafana/grafana/pull/24580), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Results of stats queries are now grouped. [#24396](https://github.com/grafana/grafana/pull/24396), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Usability improvements. [#24447](https://github.com/grafana/grafana/pull/24447), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard**: Allow editing provisioned dashboard JSON and add confirmation when JSON is copied to dashboard. [#24680](https://github.com/grafana/grafana/pull/24680), [@dprokop](https://github.com/dprokop) +- **Dashboard**: Fix for strange "dashboard not found" errors when opening links in dashboard settings. [#24416](https://github.com/grafana/grafana/pull/24416), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fix so default data source is selected when data source can't be found in panel editor. [#24526](https://github.com/grafana/grafana/pull/24526), [@mckn](https://github.com/mckn) +- **Dashboard**: Fixed issue changing a panel from transparent back to normal in panel editor. [#24483](https://github.com/grafana/grafana/pull/24483), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Make header names reflect the field name when exporting to CSV file from the panel inspector. [#24624](https://github.com/grafana/grafana/pull/24624), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Make sure side pane is displayed with tabs by default in panel editor. [#24636](https://github.com/grafana/grafana/pull/24636), [@dprokop](https://github.com/dprokop) +- **Data source**: Fix query/annotation help content formatting. [#24687](https://github.com/grafana/grafana/pull/24687), [@AgnesToulet](https://github.com/AgnesToulet) +- **Data source**: Fixes async mount errors. [#24579](https://github.com/grafana/grafana/pull/24579), [@Estrax](https://github.com/Estrax) +- **Data source**: Fixes saving a data source without failure when URL doesn't specify a protocol. [#24497](https://github.com/grafana/grafana/pull/24497), [@aknuds1](https://github.com/aknuds1) +- **Explore/Prometheus**: Show results of instant queries only in table. [#24508](https://github.com/grafana/grafana/pull/24508), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix rendering of react query editors. [#24593](https://github.com/grafana/grafana/pull/24593), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fixes loading more logs in logs context view. [#24135](https://github.com/grafana/grafana/pull/24135), [@Estrax](https://github.com/Estrax) +- **Graphite**: Fix schema and dedupe strategy in rollup indicators for Metrictank queries. [#24685](https://github.com/grafana/grafana/pull/24685), [@torkelo](https://github.com/torkelo) +- **Graphite**: Makes query annotations work again. [#24556](https://github.com/grafana/grafana/pull/24556), [@hugohaggmark](https://github.com/hugohaggmark) +- **Logs**: Clicking "Load more" from context overlay doesn't expand log row. [#24299](https://github.com/grafana/grafana/pull/24299), [@kaydelaney](https://github.com/kaydelaney) +- **Logs**: Fix total bytes process calculation. [#24691](https://github.com/grafana/grafana/pull/24691), [@davkal](https://github.com/davkal) +- **Org/user/team preferences**: Fixes so UI Theme can be set back to Default. [#24628](https://github.com/grafana/grafana/pull/24628), [@AgnesToulet](https://github.com/AgnesToulet) +- **Plugins**: Fix manifest validation. [#24573](https://github.com/grafana/grafana/pull/24573), [@aknuds1](https://github.com/aknuds1) +- **Provisioning**: Use proxy as default access mode in provisioning. [#24669](https://github.com/grafana/grafana/pull/24669), [@bergquist](https://github.com/bergquist) +- **Search**: Fix select item when pressing enter and Grafana is served using a sub path. [#24634](https://github.com/grafana/grafana/pull/24634), [@tskarhed](https://github.com/tskarhed) +- **Search**: Save folder expanded state. [#24496](https://github.com/grafana/grafana/pull/24496), [@Clarity-89](https://github.com/Clarity-89) +- **Security**: Tag value sanitization fix in OpenTSDB data source. [#24539](https://github.com/grafana/grafana/pull/24539), [@rotemreiss](https://github.com/rotemreiss) +- **Table**: Do not include angular options in options when switching from angular panel. [#24684](https://github.com/grafana/grafana/pull/24684), [@torkelo](https://github.com/torkelo) +- **Table**: Fixed persisting column resize for time series fields. [#24505](https://github.com/grafana/grafana/pull/24505), [@torkelo](https://github.com/torkelo) +- **Table**: Fixes Cannot read property subRows of null. [#24578](https://github.com/grafana/grafana/pull/24578), [@hugohaggmark](https://github.com/hugohaggmark) +- **Time picker**: Fixed so you can enter a relative range in the time picker without being converted to absolute range. [#24534](https://github.com/grafana/grafana/pull/24534), [@mckn](https://github.com/mckn) +- **Transformations**: Make transform dropdowns not cropped. [#24615](https://github.com/grafana/grafana/pull/24615), [@dprokop](https://github.com/dprokop) +- **Transformations**: Sort order should be preserved as entered by user when using the reduce transformation. [#24494](https://github.com/grafana/grafana/pull/24494), [@hugohaggmark](https://github.com/hugohaggmark) +- **Units**: Adds scale symbol for currencies with suffixed symbol. [#24678](https://github.com/grafana/grafana/pull/24678), [@hugohaggmark](https://github.com/hugohaggmark) +- **Variables**: Fixes filtering options with more than 1000 entries. [#24614](https://github.com/grafana/grafana/pull/24614), [@hugohaggmark](https://github.com/hugohaggmark) +- **Variables**: Fixes so Textbox variables read value from url. [#24623](https://github.com/grafana/grafana/pull/24623), [@hugohaggmark](https://github.com/hugohaggmark) +- **Zipkin**: Fix error when span contains remoteEndpoint. [#24524](https://github.com/grafana/grafana/pull/24524), [@aocenas](https://github.com/aocenas) +- **SAML**: Switch from email to login for user login attribute mapping (Enterprise) + +# 7.0.0-beta3 (2020-05-08) + +### Features / Enhancements + +- **Docker**: Upgrade to Alpine 3.11. [#24056](https://github.com/grafana/grafana/pull/24056), [@aknuds1](https://github.com/aknuds1) +- **Forms**: Remove Forms namespace [BREAKING]. Will cause all `Forms` imports to stop working. See migration guide in [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md)[#24378](https://github.com/grafana/grafana/pull/24378), [@tskarhed](https://github.com/tskarhed) + +### Bug Fixes + +- **CloudWatch**: Fix error with expression only query. [#24362](https://github.com/grafana/grafana/pull/24362), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Fix building of raw document queries resulting in error Unknown BaseAggregationBuilder error. [#24403](https://github.com/grafana/grafana/pull/24403), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Fix for prometheus legend formats for instant time series queries. [#24407](https://github.com/grafana/grafana/pull/24407), [@torkelo](https://github.com/torkelo) + +# 7.0.0-beta2 (2020-05-07) + +## Breaking changes + +- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). +- **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). +- **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. +- **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. + +**Deprecation warnings** + +- Scripted dashboards are now deprecated. The feature is not removed but will be in a future release. We hope to address the underlying requirement of dynamic dashboards in a different way. [#24059](https://github.com/grafana/grafana/issues/24059) + +### Features / Enhancements + +- **CloudWatch**: Adds more examples to CloudWatch Logs cheatsheet. [#24288](https://github.com/grafana/grafana/pull/24288), [@kaydelaney](https://github.com/kaydelaney) +- **Elasticsearch**: Changes terms min_doc_count default from 1 to 0. [#24204](https://github.com/grafana/grafana/pull/24204), [@Estrax](https://github.com/Estrax) +- **Login Page**: New design. [#23892](https://github.com/grafana/grafana/pull/23892), [@torkelo](https://github.com/torkelo) +- **Logs**: Add log level Fatal. [#24185](https://github.com/grafana/grafana/pull/24185), [@davkal](https://github.com/davkal) +- **Loki**: Show loki datasource stats in panel inspector. [#24190](https://github.com/grafana/grafana/pull/24190), [@davkal](https://github.com/davkal) +- **Migration**: Dashboard links. [#23553](https://github.com/grafana/grafana/pull/23553), [@peterholmberg](https://github.com/peterholmberg) +- **Plugins**: Require signing of external back-end plugins. [#24075](https://github.com/grafana/grafana/pull/24075), [@aknuds1](https://github.com/aknuds1) +- **Prometheus**: Add off switch for metric/label name lookup. [#24034](https://github.com/grafana/grafana/pull/24034), [@s-h-a-d-o-w](https://github.com/s-h-a-d-o-w) +- **Search**: Bring back open search by clicking dashboard name. [#24151](https://github.com/grafana/grafana/pull/24151), [@torkelo](https://github.com/torkelo) +- **Tracing**: Header updates. [#24153](https://github.com/grafana/grafana/pull/24153), [@aocenas](https://github.com/aocenas) +- **Transformations**: Improve time series support. [#23978](https://github.com/grafana/grafana/pull/23978), [@ryantxu](https://github.com/ryantxu) + +### Bug Fixes + +- **CloudWatch logs**: Fix default region interpolation and reset log groups on region change. [#24346](https://github.com/grafana/grafana/pull/24346), [@aocenas](https://github.com/aocenas) +- **Dashboard**: Fix for folder picker menu not being visible outside modal when saving dashboard. [#24296](https://github.com/grafana/grafana/pull/24296), [@tskarhed](https://github.com/tskarhed) +- **Dashboard**: Go to explore now works even after discarding dashboard changes. [#24149](https://github.com/grafana/grafana/pull/24149), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Only show cache timeout option in panel edit if enabled in data source plugin json. [#24095](https://github.com/grafana/grafana/pull/24095), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Propagate unhandled errors when saving dashboard. [#24081](https://github.com/grafana/grafana/pull/24081), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Variable without a current value in json model causes crash on load. [#24261](https://github.com/grafana/grafana/pull/24261), [@torkelo](https://github.com/torkelo) +- **DashboardManager**: Disable editing if there are no folder permissions. [#24237](https://github.com/grafana/grafana/pull/24237), [@tskarhed](https://github.com/tskarhed) +- **DataLinks**: Do not add empty links. [#24088](https://github.com/grafana/grafana/pull/24088), [@dprokop](https://github.com/dprokop) +- **Explore/Loki**: Removes old query syntax support for regex filter. [#24281](https://github.com/grafana/grafana/pull/24281), [@Estrax](https://github.com/Estrax) +- **Explore**: Fix showing of results of queries in table. [#24018](https://github.com/grafana/grafana/pull/24018), [@ivanahuckova](https://github.com/ivanahuckova) +- **Field options**: show field name when title option config is empty. [#24335](https://github.com/grafana/grafana/pull/24335), [@dprokop](https://github.com/dprokop) +- **Graph**: Fixed graph tooltip getting stuck / not being cleared when leaving dashboard. [#24162](https://github.com/grafana/grafana/pull/24162), [@torkelo](https://github.com/torkelo) +- **Graph**: Fixed issue with x-axis labels showing "MM/DD" after viewing dashboard with pie chart. [#24341](https://github.com/grafana/grafana/pull/24341), [@mckn](https://github.com/mckn) +- **Jaeger**: Fix how label is created in cascader. [#24164](https://github.com/grafana/grafana/pull/24164), [@aocenas](https://github.com/aocenas) +- **Loki**: Fix label matcher for log metrics queries. [#24238](https://github.com/grafana/grafana/pull/24238), [@ivanahuckova](https://github.com/ivanahuckova) +- **Panel inspect**: hides Query tab for plugins without Query ability. [#24216](https://github.com/grafana/grafana/pull/24216), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: Refresh query field metrics on data source change. [#24116](https://github.com/grafana/grafana/pull/24116), [@s-h-a-d-o-w](https://github.com/s-h-a-d-o-w) +- **Select**: Fixes so component loses focus on selecting value or pressing outside of input. [#24008](https://github.com/grafana/grafana/pull/24008), [@mckn](https://github.com/mckn) +- **Stat/Gauge/BarGauge**: Shows default cursor when missing links. [#24284](https://github.com/grafana/grafana/pull/24284), [@hugohaggmark](https://github.com/hugohaggmark) +- **Tracing**: Fix view bounds after trace change. [#23994](https://github.com/grafana/grafana/pull/23994), [@aocenas](https://github.com/aocenas) +- **Variables**: Migrates old tags format for consistency. [#24276](https://github.com/grafana/grafana/pull/24276), [@hugohaggmark](https://github.com/hugohaggmark) +- **Reporting**: Update report schedule as soon as a report is updated (Enterprise) +- **White-labeling**: Makes login title and subtitle configurable (Enterprise) + +# 7.0.0-beta1 (2020-04-28) + +## Breaking changes + +- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). +- **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). +- **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. +- **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. + +### Features / Enhancements + +- **@grafana/ui**: Create Icon component and replace icons. [#23402](https://github.com/grafana/grafana/pull/23402), [@ivanahuckova](https://github.com/ivanahuckova) +- **@grafana/ui**: Create slider component. [#22275](https://github.com/grafana/grafana/pull/22275), [@ivanahuckova](https://github.com/ivanahuckova) +- **@grafana/ui**: Remove ColorPalette component. [#23592](https://github.com/grafana/grafana/pull/23592), [@ivanahuckova](https://github.com/ivanahuckova) +- **AWS IAM**: Support for AWS EKS ServiceAccount roles for CloudWatch and S3 image upload. [#21594](https://github.com/grafana/grafana/pull/21594), [@patstrom](https://github.com/patstrom) +- **Alerting**: Adds support for basic auth in Alertmanager notifier. [#23231](https://github.com/grafana/grafana/pull/23231), [@melchiormoulin](https://github.com/melchiormoulin) +- **Alerting**: Enable Alert rule tags to override PagerDuty Severity setting. [#22736](https://github.com/grafana/grafana/pull/22736), [@AndrewBurian](https://github.com/AndrewBurian) +- **Alerting**: Handle image renderer unavailable when edit notifiers. [#23711](https://github.com/grafana/grafana/pull/23711), [@marefr](https://github.com/marefr) +- **Alerting**: Upload error image when image renderer unavailable. [#23713](https://github.com/grafana/grafana/pull/23713), [@marefr](https://github.com/marefr) +- **Alerting**: support alerting on data.Frame (that can be time series). [#22812](https://github.com/grafana/grafana/pull/22812), [@kylebrandt](https://github.com/kylebrandt) +- **Azure Monitor**: Add alerting support - Port Azure log analytics to the backend. [#23839](https://github.com/grafana/grafana/pull/23839), [@daniellee](https://github.com/daniellee) +- **Backend plugins**: Support alerting in external data source plugins. [#6841](https://github.com/grafana/grafana/issues/6841) +- **Build**: Bundle plugins. [#23787](https://github.com/grafana/grafana/pull/23787), [@aknuds1](https://github.com/aknuds1) +- **Build**: Remove usage of Go vendoring. [#23796](https://github.com/grafana/grafana/pull/23796), [@kylebrandt](https://github.com/kylebrandt) +- **Build**: Upgrade to Go 1.14. [#23371](https://github.com/grafana/grafana/pull/23371), [@aknuds1](https://github.com/aknuds1) +- **CloudWatch**: Added AWS Chatbot metrics and dimensions. [#23516](https://github.com/grafana/grafana/pull/23516), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added Cassandra namespace. [#23299](https://github.com/grafana/grafana/pull/23299), [@vikkyomkar](https://github.com/vikkyomkar) +- **CloudWatch**: Added missing Cassandra metrics. [#23467](https://github.com/grafana/grafana/pull/23467), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Adds support for Cloudwatch Logs. [#23566](https://github.com/grafana/grafana/pull/23566), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch**: Prefer webIdentity over EC2 role. [#23452](https://github.com/grafana/grafana/pull/23452), [@dnascimento](https://github.com/dnascimento) +- **CloudWatch**: Prefer webIdentity over EC2 role also when assuming a role. [#23807](https://github.com/grafana/grafana/pull/23807), [@bruecktech](https://github.com/bruecktech) +- **Components**: IconButton. [#23510](https://github.com/grafana/grafana/pull/23510), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Add failsafe for slug generation. [#23709](https://github.com/grafana/grafana/pull/23709), [@sakjur](https://github.com/sakjur) +- **Dashboard**: Enforce minimum dashboard refresh interval to 5 seconds per default. [#23929](https://github.com/grafana/grafana/pull/23929), [@marefr](https://github.com/marefr) +- **Dashboard**: Handle no renderer available in panel share dialog. [#23856](https://github.com/grafana/grafana/pull/23856), [@marefr](https://github.com/marefr) +- **Dashboard**: Support additional variable format options (singlequote, doublequote, sqlstring). [#21622](https://github.com/grafana/grafana/pull/21622), [@xiaobeiyang](https://github.com/xiaobeiyang) +- **Dashboard**: Support data links via field overrides. [#23590](https://github.com/grafana/grafana/pull/23590), [@dprokop](https://github.com/dprokop) +- **Data source**: Max data points now used in interval calculation for all data sources. [#23915](https://github.com/grafana/grafana/pull/23915), [@torkelo](https://github.com/torkelo) +- **Database**: Order results in UserSearch by username/email. [#23328](https://github.com/grafana/grafana/pull/23328), [@aknuds1](https://github.com/aknuds1) +- **Database**: Update the xorm dependency to v0.8.1. [#22376](https://github.com/grafana/grafana/pull/22376), [@novalagung](https://github.com/novalagung) +- **Docker**: Upgrade to Ubuntu 20.04 in Dockerfiles. [#23852](https://github.com/grafana/grafana/pull/23852), [@aknuds1](https://github.com/aknuds1) +- **Docs**: Adding API reference documentation support for the packages libraries. [#21931](https://github.com/grafana/grafana/pull/21931), [@mckn](https://github.com/mckn) +- **Tracing**: Add trace UI to show traces from tracing datasources and Jaeger datasource. [#23047](https://github.com/grafana/grafana/pull/23047), [@aocenas](https://github.com/aocenas) +- **Frontend**: Adding support to select preferred timezone for presentation of date and time values. [#23586](https://github.com/grafana/grafana/pull/23586), [@mckn](https://github.com/mckn) +- **Grafana Toolkit**: Adds template for backend data source. [#23864](https://github.com/grafana/grafana/pull/23864), [@bergquist](https://github.com/bergquist) +- **Graphite**: Rollup indicator and custom meta data inspector. [#22738](https://github.com/grafana/grafana/pull/22738), [@torkelo](https://github.com/torkelo) +- **HTTP API**: Allow assigning a specific organization when creating a new user. [#21775](https://github.com/grafana/grafana/pull/21775), [@Sytten](https://github.com/Sytten) +- **Image Rendering**: New setting to control render request concurrency. [#23950](https://github.com/grafana/grafana/pull/23950), [@marefr](https://github.com/marefr) +- **Image Rendering**: Remove PhantomJS support. [#23460](https://github.com/grafana/grafana/pull/23460), [@marefr](https://github.com/marefr) +- **Logs**: Derived fields link design. [#23695](https://github.com/grafana/grafana/pull/23695), [@aocenas](https://github.com/aocenas) +- **Metrics**: Add image rendering metrics. [#23827](https://github.com/grafana/grafana/pull/23827), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Metrics**: Instrument backend plugin requests. [#23346](https://github.com/grafana/grafana/pull/23346), [@bergquist](https://github.com/bergquist) +- **Migration**: Add old Input to legacy namespace. [#23286](https://github.com/grafana/grafana/pull/23286), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Admin org edit page. [#23866](https://github.com/grafana/grafana/pull/23866), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Alerting - notifications list. [#22548](https://github.com/grafana/grafana/pull/22548), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Change password. [#23623](https://github.com/grafana/grafana/pull/23623), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Create org. [#22542](https://github.com/grafana/grafana/pull/22542), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Data/Panel link editor. [#23778](https://github.com/grafana/grafana/pull/23778), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Final components to LegacyForms. [#23707](https://github.com/grafana/grafana/pull/23707), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Layout Selector. [#23790](https://github.com/grafana/grafana/pull/23790), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Migrate admin/users. [#22759](https://github.com/grafana/grafana/pull/22759), [@mckn](https://github.com/mckn) +- **Migration**: Migrates ad hoc variable type to react/redux. [#22784](https://github.com/grafana/grafana/pull/22784), [@mckn](https://github.com/mckn) +- **Migration**: Move Switch from Forms namespace. [#23386](https://github.com/grafana/grafana/pull/23386), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Move last components from Forms namespace. [#23556](https://github.com/grafana/grafana/pull/23556), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Move old Switch to legacy namespace. [#23357](https://github.com/grafana/grafana/pull/23357), [@tskarhed](https://github.com/tskarhed) +- **Migration**: New datasource. [#23221](https://github.com/grafana/grafana/pull/23221), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Org users page. [#23372](https://github.com/grafana/grafana/pull/23372), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Orgs list. [#23821](https://github.com/grafana/grafana/pull/23821), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Remove Button from Forms namespace. [#23105](https://github.com/grafana/grafana/pull/23105), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Teams and alert list. [#23810](https://github.com/grafana/grafana/pull/23810), [@tskarhed](https://github.com/tskarhed) +- **Migration**: TextArea from Forms namespace. [#23436](https://github.com/grafana/grafana/pull/23436), [@tskarhed](https://github.com/tskarhed) +- **Migration**: User edit. [#23110](https://github.com/grafana/grafana/pull/23110), [@tskarhed](https://github.com/tskarhed) +- **OAuth**: Adds Okta provider. [#22972](https://github.com/grafana/grafana/pull/22972), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **OAuth**: Introduce new setting for configuring max age of OAuth state cookie. [#23195](https://github.com/grafana/grafana/pull/23195), [@rtrompier](https://github.com/rtrompier) +- **Plugins**: Add deprecation notice to setEditor method in PanelPlugin. [#23895](https://github.com/grafana/grafana/pull/23895), [@dprokop](https://github.com/dprokop) +- **Plugins**: Adds support for URL params in plugin routes. [#23503](https://github.com/grafana/grafana/pull/23503), [@daniellee](https://github.com/daniellee) +- **Plugins**: Fluent API for custom field config and panel options creation for PanelPlugin. [#23070](https://github.com/grafana/grafana/pull/23070), [@dprokop](https://github.com/dprokop) +- **Plugins**: Hide plugins page from viewers, and limit /api/plugins to only core plugins when called by viewer role. [#21901](https://github.com/grafana/grafana/pull/21901), [@dprokop](https://github.com/dprokop) +- **Postgres**: Add SSL support for datasource. [#21341](https://github.com/grafana/grafana/pull/21341), [@ryankurte](https://github.com/ryankurte) +- **Prometheus**: Render missing labels in legend formats as an empty string. [#22355](https://github.com/grafana/grafana/pull/22355), [@Hixon10](https://github.com/Hixon10) +- **Provisioning**: Allows specifying uid for datasource and use that in derived fields. [#23585](https://github.com/grafana/grafana/pull/23585), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Validate that dashboard providers have unique names. [#22898](https://github.com/grafana/grafana/pull/22898), [@youshy](https://github.com/youshy) +- **Search**: Replace search implementation. [#23855](https://github.com/grafana/grafana/pull/23855), [@sakjur](https://github.com/sakjur) +- **Search**: migrate dashboard search to react. [#23274](https://github.com/grafana/grafana/pull/23274), [@Clarity-89](https://github.com/Clarity-89) +- **Server**: Don't include trailing slash in cookie path when hosting Grafana in a sub path. [#22265](https://github.com/grafana/grafana/pull/22265), [@consideRatio](https://github.com/consideRatio) +- **Stackdriver**: Support for SLO queries. [#22917](https://github.com/grafana/grafana/pull/22917), [@sunker](https://github.com/sunker) +- **Table**: Add support for organizing fields/columns. [#23135](https://github.com/grafana/grafana/pull/23135), [@mckn](https://github.com/mckn) +- **Table**: Improvements to column resizing, style and alignment. [#23663](https://github.com/grafana/grafana/pull/23663), [@torkelo](https://github.com/torkelo) +- **Table**: upgrades react-table to 7.0.0 and typings. [#23247](https://github.com/grafana/grafana/pull/23247), [@hugohaggmark](https://github.com/hugohaggmark) +- **Table**: Handle column overflow and horizontal scrolling in table panel. [#4157](https://github.com/grafana/grafana/issues/4157) +- **Tracing**: Dark theme styling for TraceView. [#23406](https://github.com/grafana/grafana/pull/23406), [@aocenas](https://github.com/aocenas) +- **Tracing**: Zipkin datasource. [#23829](https://github.com/grafana/grafana/pull/23829), [@aocenas](https://github.com/aocenas) +- **Transformations**: Adds labels as fields transformer. [#23703](https://github.com/grafana/grafana/pull/23703), [@hugohaggmark](https://github.com/hugohaggmark) +- **Transformations**: Improve UI and add some love to filter by name. [#23751](https://github.com/grafana/grafana/pull/23751), [@dprokop](https://github.com/dprokop) +- **Transformations**: calculate a new field based on the row values. [#23675](https://github.com/grafana/grafana/pull/23675), [@ryantxu](https://github.com/ryantxu) +- **Units**: add (IEC) and (Metric) to bits and bytes. [#23175](https://github.com/grafana/grafana/pull/23175), [@flopp999](https://github.com/flopp999) +- **Usagestats**: Add usage stats about what type of data source is used in alerting. [#23125](https://github.com/grafana/grafana/pull/23125), [@bergquist](https://github.com/bergquist) +- **delete old dashboard versions in multiple batches**. [#23348](https://github.com/grafana/grafana/pull/23348), [@DanCech](https://github.com/DanCech) +- **grafana/data**: PanelTypeChangedHandler API update to use PanelModel instead of panel options object [BREAKING]. [#22754](https://github.com/grafana/grafana/pull/22754), [@dprokop](https://github.com/dprokop) +- **grafana/ui**: Add basic horizontal and vertical layout components. [#22303](https://github.com/grafana/grafana/pull/22303), [@dprokop](https://github.com/dprokop) +- **Auth** SAML Role and Team Sync (Enterprise) +- **Presence Indicators**: Display the avatars of active users on dashboards (Enterprise) +- **Reporting**: Makes it possible to disable the scheduler (Enterprise) +- **Dashboard**: Dashboard usage view (Enterprise) +- **Reporting** Makes it possible to trigger report emails without scheduler (Enterprise) +- **Search**: Sorting based on dashboard views and errors (Enterprise) +- **Reporting**: Improved landscape mode and panel image quality (Enterprise) +- **Reporting**: Adds config setting for image_scale_factor of panel images (Enterprise) + +### Bug Fixes + +- **@grafana/ui**: Fix time range when only partial datetime is provided. [#23122](https://github.com/grafana/grafana/pull/23122), [@ivanahuckova](https://github.com/ivanahuckova) +- **Alerting**: Only include image in notifier when enabled. [#23194](https://github.com/grafana/grafana/pull/23194), [@marefr](https://github.com/marefr) +- **Alerting**: Basic auth should not be required in the Alertmanager notifier. [#23691](https://github.com/grafana/grafana/pull/23691), [@bergquist](https://github.com/bergquist) +- **Alerting**: Translate notification IDs to UIDs when extracting alert rules. [#19882](https://github.com/grafana/grafana/pull/19882), [@aSapien](https://github.com/aSapien) +- **Azure Monitor**: Fix for application insights Azure China plugin route. [#23877](https://github.com/grafana/grafana/pull/23877), [@daniellee](https://github.com/daniellee) +- **CloudWatch**: Add ServerlessDatabaseCapacity to AWS/RDS metrics. [#23635](https://github.com/grafana/grafana/pull/23635), [@jackstevenson](https://github.com/jackstevenson) +- **Dashboard**: Fix global variable "\_\_org.id". [#23362](https://github.com/grafana/grafana/pull/23362), [@vikkyomkar](https://github.com/vikkyomkar) +- **Dashboard**: Handle min refresh interval when importing dashboard. [#23959](https://github.com/grafana/grafana/pull/23959), [@marefr](https://github.com/marefr) +- **DataSourceProxy**: Handle URL parsing error. [#23731](https://github.com/grafana/grafana/pull/23731), [@aknuds1](https://github.com/aknuds1) +- **Frontend**: Fix sorting of organization popup in alphabetical order. [#22259](https://github.com/grafana/grafana/pull/22259), [@vikkyomkar](https://github.com/vikkyomkar) +- **Image Rendering**: Make it work using serve_from_sub_path configured. [#23706](https://github.com/grafana/grafana/pull/23706), [@marefr](https://github.com/marefr) +- **Image rendering**: Fix missing icon on plugins list. [#23958](https://github.com/grafana/grafana/pull/23958), [@marefr](https://github.com/marefr) +- **Logs**: Fix error when non-string log level supplied. [#23654](https://github.com/grafana/grafana/pull/23654), [@ivanahuckova](https://github.com/ivanahuckova) +- **Rich history**: Fix create url and run query for various datasources. [#23627](https://github.com/grafana/grafana/pull/23627), [@ivanahuckova](https://github.com/ivanahuckova) +- **Security**: Fix XSS vulnerability in table panel. [#23816](https://github.com/grafana/grafana/pull/23816), [@torkelo](https://github.com/torkelo) + + + +# 6.7.6 (2021-03-18) + +### Bug fixes + +* **Security**: Fix API permissions issues related to team-sync CVE-2021-28147. (Enterprise) +* **Security**: Usage insights requires signed in users CVE-2021-28148. (Enterprise) + + + + + +# 6.7.5 (2020-12-17) + +### Security + +- **SAML**: Fixes encoding/xml SAML vulnerability in Grafana Enterprise [#29875](https://github.com/grafana/grafana/issues/29875), [@bergquist](https://github.com/bergquist) + + + + + +# 6.7.4 (2020-06-03) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2020/06/03/grafana-6.7.4-and-7.0.2-released-with-important-security-fix/) + +# 6.7.3 (2020-04-23) + +### Bug Fixes + +- **Admin**: Fix Synced via LDAP message for non-LDAP external users. [#23477](https://github.com/grafana/grafana/pull/23477), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Alerting**: Fixes notifications for alerts with empty message in Google Hangouts notifier. [#23559](https://github.com/grafana/grafana/pull/23559), [@hugohaggmark](https://github.com/hugohaggmark) +- **AuthProxy**: Fixes bug where long username could not be cached.. [#22926](https://github.com/grafana/grafana/pull/22926), [@jcmcken](https://github.com/jcmcken) +- **Dashboard**: Fix saving dashboard when editing raw dashboard JSON model. [#23314](https://github.com/grafana/grafana/pull/23314), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Try to parse 8 and 15 digit numbers as timestamps if parsing of time range as date fails. [#21694](https://github.com/grafana/grafana/pull/21694), [@jessetan](https://github.com/jessetan) +- **DashboardListPanel**: Fixed problem with empty panel after going into edit mode (General folder filter being automatically added) . [#23426](https://github.com/grafana/grafana/pull/23426), [@torkelo](https://github.com/torkelo) +- **Data source**: Handle datasource withCredentials option properly. [#23380](https://github.com/grafana/grafana/pull/23380), [@hvtuananh](https://github.com/hvtuananh) +- **Security**: Fix annotation popup XSS vulnerability [#23813](https://github.com/grafana/grafana/pull/23813), [@torkelo](https://github.com/torkelo). Big thanks to Juha Laaksonen for reporting this issue. +- **Security**: Fix XSS vulnerability in table panel [#23816](https://github.com/grafana/grafana/pull/23816), [@torkelo](https://github.com/torkelo). Big thanks to Rotem Reiss for reporting this issue. +- **Server**: Exit Grafana with status code 0 if no error. [#23312](https://github.com/grafana/grafana/pull/23312), [@aknuds1](https://github.com/aknuds1) +- **TablePanel**: Fix XSS issue in header column rename (backport). [#23814](https://github.com/grafana/grafana/pull/23814), [@torkelo](https://github.com/torkelo) +- **Variables**: Fixes error when setting adhoc variable values. [#23580](https://github.com/grafana/grafana/pull/23580), [@hugohaggmark](https://github.com/hugohaggmark) + +# 6.7.2 (2020-04-02) + +### Bug Fixes + +- **BackendSrv**: Adds config to response to fix issue for external plugins that used this property . [#23032](https://github.com/grafana/grafana/pull/23032), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fixed issue with saving new dashboard after changing title . [#23104](https://github.com/grafana/grafana/pull/23104), [@dprokop](https://github.com/dprokop) +- **DataLinks**: make sure we use the correct datapoint when dataset contains null value.. [#22981](https://github.com/grafana/grafana/pull/22981), [@mckn](https://github.com/mckn) +- **Plugins**: Fixed issue for plugins that imported dateMath util . [#23069](https://github.com/grafana/grafana/pull/23069), [@mckn](https://github.com/mckn) +- **Security**: Fix for dashboard snapshot original dashboard link could contain XSS vulnerability in url. [#23254](https://github.com/grafana/grafana/pull/23254), [@torkelo](https://github.com/torkelo). Big thanks to Ahmed A. Sherif for reporting this issue. +- **Variables**: Fixes issue with too many queries being issued for nested template variables after value change. [#23220](https://github.com/grafana/grafana/pull/23220), [@torkelo](https://github.com/torkelo) +- **Plugins**: Expose promiseToDigest. [#23249](https://github.com/grafana/grafana/pull/23249), [@torkelo](https://github.com/torkelo) +- **Reporting**: Fixes issue updating a report created by someone else (Enterprise) + +# 6.7.1 (2020-03-20) + +### Bug Fixes + +- **Azure**: Fixed dropdowns not showing current value. [#22914](https://github.com/grafana/grafana/pull/22914), [@torkelo](https://github.com/torkelo) +- **BackendSrv**: only add content-type on POST, PUT requests. [#22910](https://github.com/grafana/grafana/pull/22910), [@hugohaggmark](https://github.com/hugohaggmark) +- **Panels**: Fixed size issue with panel internal size when exiting panel edit mode. [#22912](https://github.com/grafana/grafana/pull/22912), [@torkelo](https://github.com/torkelo) +- **Reporting**: fixes migrations compatibility with mysql (Enterprise) +- **Reporting**: Reduce default concurrency limit to 4 (Enterprise) + +# 6.7.0 (2020-03-19) + +### Features / Enhancements + +- **AzureMonitor**: support workspaces function for template variables. [#22882](https://github.com/grafana/grafana/pull/22882), [@daniellee](https://github.com/daniellee) +- **SQLStore**: Add migration for adding index on annotation.alert_id. [#22876](https://github.com/grafana/grafana/pull/22876), [@aknuds1](https://github.com/aknuds1) +- **TablePanel**: Enable new units picker . [#22833](https://github.com/grafana/grafana/pull/22833), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **AngularPanels**: Fixed inner height calculation for angular panels . [#22796](https://github.com/grafana/grafana/pull/22796), [@torkelo](https://github.com/torkelo) +- **BackendSrv**: makes sure provided headers are correctly recognized and set. [#22778](https://github.com/grafana/grafana/pull/22778), [@hugohaggmark](https://github.com/hugohaggmark) +- **Forms**: Fix input suffix position (caret-down in Select) . [#22780](https://github.com/grafana/grafana/pull/22780), [@torkelo](https://github.com/torkelo) +- **Graphite**: Fixed issue with query editor and next select metric now showing after selecting metric node . [#22856](https://github.com/grafana/grafana/pull/22856), [@torkelo](https://github.com/torkelo) +- **Rich History**: UX adjustments and fixes. [#22729](https://github.com/grafana/grafana/pull/22729), [@ivanahuckova](https://github.com/ivanahuckova) + +# 6.7.0-beta1 (2020-03-12) + +## Breaking changes + +- **Slack**: Removed _Mention_ setting and instead introduce _Mention Users_, _Mention Groups_, and _Mention Channel_. The first two settings require user and group IDs, respectively. This change was necessary because the way of mentioning via the Slack API [changed](https://api.slack.com/changelog/2017-09-the-one-about-usernames) and mentions in Slack notifications no longer worked. +- **Alerting**: Reverts the behavior of `diff` and `percent_diff` to not always be absolute. Something we introduced by mistake in [6.1.0](https://github.com/grafana/grafana/commit/28eaac3a9c7082e8c496005c1cb66b4b70a4f82f). Alerting now support `diff()`, `diff_abs()`, `percent_diff()` and `percent_diff_abs()`. [#21338](https://github.com/grafana/grafana/pull/21338) + +### Notice about changes in backendSrv for plugin authors + +In our mission to migrate away from AngularJS to React we have removed all AngularJS dependencies in the core data retrieval service `backendSrv`. + +Removing the AngularJS dependencies in `backendSrv` has the unfortunate side effect of AngularJS digest no longer being triggered for any request made with `backendSrv`. Because of this, external plugins using `backendSrv` directly may suffer from strange behaviour in the UI. + +To remedy this issue, as a plugin author you need to trigger the digest after a direct call to `backendSrv`. + +Example: + +```js +backendSrv.get(‘http://your.url/api’).then(result => { + this.result = result; + this.$scope.$digest(); +}); +``` + +Another unfortunate outcome from this work in `backendSrv` is that the response format for `.headers()` changed from a function to an object. + +To make your plugin work on 6.7.x as well as on previous versions you should add something like the following: + +```typescript +let responseHeaders = response.headers; +if (!responseHeaders) { + return null; +} + +// Support pre 6.7 angular HTTP rather than fetch +if (typeof responseHeaders === 'function') { + responseHeaders = responseHeaders(); +} +``` + +You can test your plugin with the `master` branch version of Grafana. + +### Features / Enhancements + +- **API**: Include IP address when logging request error. [#21596](https://github.com/grafana/grafana/pull/21596), [@thedeveloperr](https://github.com/thedeveloperr) +- **Alerting**: Support passing tags to Pagerduty and allow notification on specific event categories . [#21335](https://github.com/grafana/grafana/pull/21335), [@johntdyer](https://github.com/johntdyer) +- **Chore**: Remove angular dependency from backendSrv. [#20999](https://github.com/grafana/grafana/pull/20999), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch**: Surround dimension names with double quotes. [#22222](https://github.com/grafana/grafana/pull/22222), [@jeet-parekh](https://github.com/jeet-parekh) +- **CloudWatch**: updated metrics and dimensions for Athena, DocDB, and Route53Resolver. [#22604](https://github.com/grafana/grafana/pull/22604), [@jeet-parekh](https://github.com/jeet-parekh) +- **Cloudwatch**: add Usage Metrics. [#22179](https://github.com/grafana/grafana/pull/22179), [@passing](https://github.com/passing) +- **Dashboard**: Adds support for a global minimum dashboard refresh interval. [#19416](https://github.com/grafana/grafana/pull/19416), [@lfroment0](https://github.com/lfroment0) +- **DatasourceEditor**: Add UI to edit custom HTTP headers. [#17846](https://github.com/grafana/grafana/pull/17846), [@adrien-f](https://github.com/adrien-f) +- **Elastic**: To get fields, start with today's index and go backwards. [#22318](https://github.com/grafana/grafana/pull/22318), [@ChadiEM](https://github.com/ChadiEM) +- **Explore**: Rich history. [#22570](https://github.com/grafana/grafana/pull/22570), [@ivanahuckova](https://github.com/ivanahuckova) +- **Graph**: canvas's Stroke is executed after loop. [#22610](https://github.com/grafana/grafana/pull/22610), [@merturl](https://github.com/merturl) +- **Graphite**: Don't issue empty "select metric" queries. [#22699](https://github.com/grafana/grafana/pull/22699), [@papagian](https://github.com/papagian) +- **Image Rendering**: Store render key in remote cache to enable renderer to callback to public/load balancer URL when running in HA mode. [#22031](https://github.com/grafana/grafana/pull/22031), [@marefr](https://github.com/marefr) +- **LDAP**: Add fallback to search_base_dns if group_search_base_dns is undefined.. [#21263](https://github.com/grafana/grafana/pull/21263), [@bb-Ricardo](https://github.com/bb-Ricardo) +- **OAuth**: Implement Azure AD provide. [#20030](https://github.com/grafana/grafana/pull/20030), [@twendt](https://github.com/twendt) +- **Prometheus**: Implement region annotation. [#22225](https://github.com/grafana/grafana/pull/22225), [@secustor](https://github.com/secustor) +- **Prometheus**: make \$\_\_range more precise. [#21722](https://github.com/grafana/grafana/pull/21722), [@bmerry](https://github.com/bmerry) +- **Prometheus**: Do not show rate hint when increase function is used in query. [#21955](https://github.com/grafana/grafana/pull/21955), [@fredwangwang](https://github.com/fredwangwang) +- **Stackdriver**: Project selector. [#22447](https://github.com/grafana/grafana/pull/22447), [@sunker](https://github.com/sunker) +- **TablePanel**: display multi-line text. [#20210](https://github.com/grafana/grafana/pull/20210), [@michael-az](https://github.com/michael-az) +- **Templating**: Add new global built-in variables. [#21790](https://github.com/grafana/grafana/pull/21790), [@dcastanier](https://github.com/dcastanier) +- **Reporting**: add concurrent render limit to settings (Enterprise) +- **Reporting**: Add rendering timeout in settings (Enterprise) + +### Bug Fixes + +- **API**: Fix redirect issues. [#22285](https://github.com/grafana/grafana/pull/22285), [@papagian](https://github.com/papagian) +- **Alerting**: Don't include image_url field with Slack message if empty. [#22372](https://github.com/grafana/grafana/pull/22372), [@aknuds1](https://github.com/aknuds1) +- **Alerting**: Fixed bad background color for default notifications in alert tab . [#22660](https://github.com/grafana/grafana/pull/22660), [@krvajal](https://github.com/krvajal) +- **Annotations**: In table panel when setting transform to annotation, they will now show up right away without a manual refresh. [#22323](https://github.com/grafana/grafana/pull/22323), [@krvajal](https://github.com/krvajal) +- **Azure Monitor**: Fix app insights source to allow for new **timeFrom and **timeTo. [#21879](https://github.com/grafana/grafana/pull/21879), [@ChadNedzlek](https://github.com/ChadNedzlek) +- **BackendSrv**: Fixes POST body for form data. [#21714](https://github.com/grafana/grafana/pull/21714), [@hugohaggmark](https://github.com/hugohaggmark) +- **CloudWatch**: Credentials cache invalidation fix. [#22473](https://github.com/grafana/grafana/pull/22473), [@sunker](https://github.com/sunker) +- **CloudWatch**: Expand alias variables when query yields no result. [#22695](https://github.com/grafana/grafana/pull/22695), [@sunker](https://github.com/sunker) +- **Dashboard**: Fix bug with NaN in alerting. [#22053](https://github.com/grafana/grafana/pull/22053), [@a-melnyk](https://github.com/a-melnyk) +- **Explore**: Fix display of multiline logs in log panel and explore. [#22057](https://github.com/grafana/grafana/pull/22057), [@thomasdraebing](https://github.com/thomasdraebing) +- **Heatmap**: Legend color range is incorrect when using custom min/max. [#21748](https://github.com/grafana/grafana/pull/21748), [@sv5d](https://github.com/sv5d) +- **Security**: Fixed XSS issue in dashboard history diff . [#22680](https://github.com/grafana/grafana/pull/22680), [@torkelo](https://github.com/torkelo) +- **StatPanel**: Fixes base color is being used for null values . + [#22646](https://github.com/grafana/grafana/pull/22646), [@torkelo](https://github.com/torkelo) + +# 6.6.2 (2020-02-20) + +### Features / Enhancements + +- **Data proxy**: Log proxy errors using Grafana logger. [#22174](https://github.com/grafana/grafana/pull/22174), [@bergquist](https://github.com/bergquist) +- **Metrics**: Add gauge for requests currently in flight. [#22168](https://github.com/grafana/grafana/pull/22168), [@bergquist](https://github.com/bergquist) + +### Bug Fixes + +- **@grafana/ui**: Fix displaying of bars in React Graph. [#21968](https://github.com/grafana/grafana/pull/21968), [@ivanahuckova](https://github.com/ivanahuckova) +- **API**: Fix redirect issue when configured to use a subpath. [#21652](https://github.com/grafana/grafana/pull/21652), [@briangann](https://github.com/briangann) +- **API**: Improve recovery middleware when response already been written. [#22256](https://github.com/grafana/grafana/pull/22256), [@marefr](https://github.com/marefr) +- **Auth**: Don't rotate auth token when requests are cancelled by client. [#22106](https://github.com/grafana/grafana/pull/22106), [@bergquist](https://github.com/bergquist) +- **Docker**: Downgrade to 18.04 LTS base image. [#22313](https://github.com/grafana/grafana/pull/22313), [@aknuds1](https://github.com/aknuds1) +- **Elasticsearch**: Fix auto interval for date histogram in explore logs mode. [#21937](https://github.com/grafana/grafana/pull/21937), [@ivanahuckova](https://github.com/ivanahuckova) +- **Image Rendering**: Fix PhantomJS compatibility with es2016 node dependencies. [#21677](https://github.com/grafana/grafana/pull/21677), [@dprokop](https://github.com/dprokop) +- **Links**: Assure base url when single stat, panel and data links are built. [#21956](https://github.com/grafana/grafana/pull/21956), [@dprokop](https://github.com/dprokop) +- **Loki, Prometheus**: Fix PromQL and LogQL syntax highlighting. [#21944](https://github.com/grafana/grafana/pull/21944), [@ivanahuckova](https://github.com/ivanahuckova) +- **OAuth**: Enforce auto_assign_org_id setting when role mapping enabled using Generic OAuth. [#22268](https://github.com/grafana/grafana/pull/22268), [@aknuds1](https://github.com/aknuds1) +- **Prometheus**: Updates explore query editor to prevent it from throwing error on edit. [#21605](https://github.com/grafana/grafana/pull/21605), [@Estrax](https://github.com/Estrax) +- **Server**: Reorder cipher suites for better security. [#22101](https://github.com/grafana/grafana/pull/22101), [@tofu-rocketry](https://github.com/tofu-rocketry) +- **TimePicker**: fixing weird behavior with calendar when switching between months/years . [#22253](https://github.com/grafana/grafana/pull/22253), [@mckn](https://github.com/mckn) + +# 6.6.1 (2020-02-06) + +### Bug Fixes + +- **Annotations**: Change indices and rewrites annotation find query to improve database query performance. [#21915](https://github.com/grafana/grafana/pull/21915), [@papagian](https://github.com/papagian), [@marefr](https://github.com/marefr), [@kylebrandt](https://github.com/kylebrandt) +- **Azure Monitor**: Fix Application Insights API key field to allow input. [#21738](https://github.com/grafana/grafana/pull/21738), [@shavonn](https://github.com/shavonn) +- **BarGauge**: Fix so we properly display the "no result" value when query returns empty result. [#21791](https://github.com/grafana/grafana/pull/21791), [@mckn](https://github.com/mckn) +- **Datasource**: Show access (Browser/Server) select on the Prometheus datasource. [#21833](https://github.com/grafana/grafana/pull/21833), [@jorgelbg](https://github.com/jorgelbg) +- **DatasourceSettings**: Fixed issue navigating away from data source settings page. [#21841](https://github.com/grafana/grafana/pull/21841), [@torkelo](https://github.com/torkelo) +- **Graph Panel**: Fix typo in thresholds form. [#21903](https://github.com/grafana/grafana/pull/21903), [@orendain](https://github.com/orendain) +- **Graphite**: Fixed issue with functions with multiple required params and no defaults caused params that could not be edited (groupByNodes groupByTags). [#21814](https://github.com/grafana/grafana/pull/21814), [@torkelo](https://github.com/torkelo) +- **Image Rendering**: Fix render of graph panel legend aligned to the right using Grafana image renderer plugin/service. [#21854](https://github.com/grafana/grafana/pull/21854), [@marefr](https://github.com/marefr) +- **Metrics**: Adds back missing summary quantiles. [#21858](https://github.com/grafana/grafana/pull/21858), [@kogent](https://github.com/kogent) +- **OpenTSDB**: Adds back missing ngInject to make it work again. [#21796](https://github.com/grafana/grafana/pull/21796), [@marefr](https://github.com/marefr) +- **Plugins**: Fix routing in app plugin pages. [#21847](https://github.com/grafana/grafana/pull/21847), [@dprokop](https://github.com/dprokop) +- **Prometheus**: Fixes default step value for annotation query. [#21934](https://github.com/grafana/grafana/pull/21934), [@hugohaggmark](https://github.com/hugohaggmark) +- **Quota**: Makes LDAP + Quota work for the first login of a new user. [#21949](https://github.com/grafana/grafana/pull/21949), [@xlson](https://github.com/xlson) +- **StatPanels**: Fixed change from singlestat to Gauge / BarGauge / Stat where default min & max (0, 100) was copied . [#21820](https://github.com/grafana/grafana/pull/21820), [@torkelo](https://github.com/torkelo) +- **TimePicker**: Should display in kiosk mode. [#21816](https://github.com/grafana/grafana/pull/21816), [@evgbibko](https://github.com/evgbibko) +- **grafana/toolkit**: Fix failing linter when there were lint issues. [#21849](https://github.com/grafana/grafana/pull/21849), [@dprokop](https://github.com/dprokop) + +# 6.6.0 (2020-01-27) + +### Features / Enhancements + +- **CloudWatch**: Add DynamoDB Accelerator (DAX) metrics & dimensions. [#21644](https://github.com/grafana/grafana/pull/21644), [@kenju](https://github.com/kenju) +- **CloudWatch**: Auto period snap to next higher period. [#21659](https://github.com/grafana/grafana/pull/21659), [@sunker](https://github.com/sunker) +- **Template variables**: Add error for failed query variable on time range update. [#21731](https://github.com/grafana/grafana/pull/21731), [@tskarhed](https://github.com/tskarhed) +- **XSS**: Sanitize column link. [#21735](https://github.com/grafana/grafana/pull/21735), [@tskarhed](https://github.com/tskarhed) + +### Bug Fixes + +- **Elasticsearch**: Fix adhoc variable filtering for logs query. [#21346](https://github.com/grafana/grafana/pull/21346), [@ceh](https://github.com/ceh) +- **Explore**: Fix colors for log level when level value is capitalised. [#21646](https://github.com/grafana/grafana/pull/21646), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix context view in logs, where some rows may have been filtered out.. [#21729](https://github.com/grafana/grafana/pull/21729), [@aocenas](https://github.com/aocenas) +- **Loki**: Fix Loki with repeated panels and interpolation for Explore. [#21685](https://github.com/grafana/grafana/pull/21685), [@ivanahuckova](https://github.com/ivanahuckova) +- **SQLStore**: Fix PostgreSQL failure to create organisation for first time. [#21648](https://github.com/grafana/grafana/pull/21648), [@papagian](https://github.com/papagian) + +# 6.6.0-beta1 (2020-01-20) + +## Breaking changes + +- **PagerDuty**: Change `payload.custom_details` field in PagerDuty notification to be a JSON object instead of a string. +- **Security**: The `[security]` setting `cookie_samesite` configured to `none` now renders cookies with `SameSite=None` attribute compared to before where no `SameSite` attribute was added to cookies. To get the old behavior, use value `disabled` instead of `none`. Refer to [Upgrade Grafana](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v6-6) for more information. + +### Features / Enhancements + +- **Graphite**: Add Metrictank dashboard to Graphite datasource +- **Admin**: Show name of user in users table view. [#18108](https://github.com/grafana/grafana/pull/18108), [@eleijonmarck](https://github.com/eleijonmarck) +- **Alerting**: Add configurable severity support for PagerDuty notifier. [#19425](https://github.com/grafana/grafana/pull/19425), [@yemble](https://github.com/yemble) +- **Alerting**: Add more information to webhook notifications. [#20420](https://github.com/grafana/grafana/pull/20420), [@michael-az](https://github.com/michael-az) +- **Alerting**: Add support for sending tags in OpsGenie notifier. [#20810](https://github.com/grafana/grafana/pull/20810), [@aSapien](https://github.com/aSapien) +- **Alerting**: Added fallbackText to Google Chat notifier. [#21464](https://github.com/grafana/grafana/pull/21464), [@alvarolmedo](https://github.com/alvarolmedo) +- **Alerting**: Adds support for sending a single email to all recipients in email notifier. [#21091](https://github.com/grafana/grafana/pull/21091), [@marefr](https://github.com/marefr) +- **Alerting**: Enable setting of OpsGenie priority via a tag. [#21298](https://github.com/grafana/grafana/pull/21298), [@zabullet](https://github.com/zabullet) +- **Alerting**: Use fully qualified status emoji in Threema notifier. [#21305](https://github.com/grafana/grafana/pull/21305), [@dbrgn](https://github.com/dbrgn) +- **Alerting**: new min_interval_seconds option to enforce a minimum evaluation frequency . [#21188](https://github.com/grafana/grafana/pull/21188), [@papagian](https://github.com/papagian) +- **CloudWatch**: Calculate period based on time range. [#21471](https://github.com/grafana/grafana/pull/21471), [@sunker](https://github.com/sunker) +- **CloudWatch**: Display partial result in graph when max DP/call limit is reached . [#21533](https://github.com/grafana/grafana/pull/21533), [@sunker](https://github.com/sunker) +- **CloudWatch**: ECS/ContainerInsights metrics support. [#21125](https://github.com/grafana/grafana/pull/21125), [@briancurt](https://github.com/briancurt) +- **CloudWatch**: Upgrade aws-sdk-go. [#20510](https://github.com/grafana/grafana/pull/20510), [@mtanda](https://github.com/mtanda) +- **DataLinks**: allow using values from other fields in the same row (cells). [#21478](https://github.com/grafana/grafana/pull/21478), [@ryantxu](https://github.com/ryantxu) +- **Editor**: Ignore closing brace when it was added by editor. [#21172](https://github.com/grafana/grafana/pull/21172), [@davkal](https://github.com/davkal) +- **Explore**: Context tooltip to copy labels and values from graph. [#21405](https://github.com/grafana/grafana/pull/21405), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Log message line wrapping options for logs. [#20360](https://github.com/grafana/grafana/pull/20360), [@ivanahuckova](https://github.com/ivanahuckova) +- **Forms**: introduce RadioButtonGroup. [#20828](https://github.com/grafana/grafana/pull/20828), [@dprokop](https://github.com/dprokop) +- **Frontend**: Changes in Redux location should not strip subpath from location url. [#20161](https://github.com/grafana/grafana/pull/20161), [@wybczu](https://github.com/wybczu) +- **Graph**: Add fill gradient option to series override line fill. [#20941](https://github.com/grafana/grafana/pull/20941), [@hendrikvh](https://github.com/hendrikvh) +- **Graphite**: Add metrictank dashboard to Graphite datasource. [#20776](https://github.com/grafana/grafana/pull/20776), [@Dieterbe](https://github.com/Dieterbe) +- **Graphite**: Do not change query when opening the query editor and there is no data. [#21588](https://github.com/grafana/grafana/pull/21588), [@daniellee](https://github.com/daniellee) +- **Gravatar**: Use HTTPS by default. [#20964](https://github.com/grafana/grafana/pull/20964), [@jiajunhuang](https://github.com/jiajunhuang) +- **Loki**: Support for template variable queries. [#20697](https://github.com/grafana/grafana/pull/20697), [@ivanahuckova](https://github.com/ivanahuckova) +- **NewsPanel**: Add news as a builtin panel. [#21128](https://github.com/grafana/grafana/pull/21128), [@ryantxu](https://github.com/ryantxu) +- **OAuth**: Removes send_client_credentials_via_post setting . [#20044](https://github.com/grafana/grafana/pull/20044), [@LK4D4](https://github.com/LK4D4) +- **OpenTSDB**: Adding lookup limit to OpenTSDB datasource settings. [#20647](https://github.com/grafana/grafana/pull/20647), [@itamarst](https://github.com/itamarst) +- **Postgres/MySQL/MSSQL**: Adds support for region annotations. [#20752](https://github.com/grafana/grafana/pull/20752), [@Bercon](https://github.com/Bercon) +- **Prometheus**: Field to specify step in Explore. [#20195](https://github.com/grafana/grafana/pull/20195), [@Estrax](https://github.com/Estrax) +- **Prometheus**: User metrics metadata to inform query hints. [#21304](https://github.com/grafana/grafana/pull/21304), [@davkal](https://github.com/davkal) +- **Renderer**: Add user-agent to remote rendering service requests. [#20956](https://github.com/grafana/grafana/pull/20956), [@kfdm](https://github.com/kfdm) +- **Security**: Add disabled option for cookie samesite attribute. [#21472](https://github.com/grafana/grafana/pull/21472), [@marefr](https://github.com/marefr) +- **Stackdriver**: Support meta labels. [#21373](https://github.com/grafana/grafana/pull/21373), [@sunker](https://github.com/sunker) +- **TablePanel, GraphPanel**: Exclude hidden columns from CSV. [#19925](https://github.com/grafana/grafana/pull/19925), [@literalplus](https://github.com/literalplus) +- **Templating**: Update variables on location changed. [#21480](https://github.com/grafana/grafana/pull/21480), [@ryantxu](https://github.com/ryantxu) +- **Tracing**: Support configuring Jaeger client from environment. [#21103](https://github.com/grafana/grafana/pull/21103), [@hairyhenderson](https://github.com/hairyhenderson) +- **Units**: Add currency and energy units. [#20428](https://github.com/grafana/grafana/pull/20428), [@anirudh-ramesh](https://github.com/anirudh-ramesh) +- **Units**: Support dynamic count and currency units. [#21279](https://github.com/grafana/grafana/pull/21279), [@ryantxu](https://github.com/ryantxu) +- **grafana/toolkit**: Add option to override webpack config. [#20872](https://github.com/grafana/grafana/pull/20872), [@sebimarkgraf](https://github.com/sebimarkgraf) +- **grafana/ui**: ConfirmModal component. [#20965](https://github.com/grafana/grafana/pull/20965), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **grafana/ui**: Create Tabs component. [#21328](https://github.com/grafana/grafana/pull/21328), [@peterholmberg](https://github.com/peterholmberg) +- **grafana/ui**: New table component. [#20991](https://github.com/grafana/grafana/pull/20991), [@peterholmberg](https://github.com/peterholmberg) +- **grafana/ui**: New updated time picker. [#20931](https://github.com/grafana/grafana/pull/20931), [@mckn](https://github.com/mckn) +- **White-labeling**: Makes it possible to customize the footer and login background (Enterprise) + +### Bug Fixes + +- **API**: Optionally list expired API keys. [#20468](https://github.com/grafana/grafana/pull/20468), [@papagian](https://github.com/papagian) +- **Alerting**: Fix custom_details to be a JSON object instead of a string in PagerDuty notifier. [#21150](https://github.com/grafana/grafana/pull/21150), [@tehGoti](https://github.com/tehGoti) +- **Alerting**: Fix image rendering and uploading timeout preventing to send alert notifications. [#21536](https://github.com/grafana/grafana/pull/21536), [@marefr](https://github.com/marefr) +- **Alerting**: Fix panic in dingding notifier . [#20378](https://github.com/grafana/grafana/pull/20378), [@csyangchen](https://github.com/csyangchen) +- **Alerting**: Fix template query validation logic. [#20721](https://github.com/grafana/grafana/pull/20721), [@okhowang](https://github.com/okhowang) +- **Alerting**: If no permission to clear history, keep the historical data. [#19007](https://github.com/grafana/grafana/pull/19007), [@lzdw](https://github.com/lzdw) +- **Alerting**: Unpausing a non-paused alert rule should not change status to Unknown. [#21375](https://github.com/grafana/grafana/pull/21375), [@vikkyomkar](https://github.com/vikkyomkar) +- **Api**: Fix returned message when enabling, disabling and deleting a non-existing user. [#21391](https://github.com/grafana/grafana/pull/21391), [@dpavlos](https://github.com/dpavlos) +- **Auth**: Rotate auth tokens at the end of requests. [#21347](https://github.com/grafana/grafana/pull/21347), [@woodsaj](https://github.com/woodsaj) +- **Azure Monitor**: Fixes error using azure monitor credentials with log analytics and non-default cloud. [#21032](https://github.com/grafana/grafana/pull/21032), [@shavonn](https://github.com/shavonn) +- **CLI**: Return error and aborts when plugin file extraction fails. [#20849](https://github.com/grafana/grafana/pull/20849), [@marefr](https://github.com/marefr) +- **CloudWatch**: Multi-valued template variable dimension alias fix. [#21541](https://github.com/grafana/grafana/pull/21541), [@sunker](https://github.com/sunker) +- **Dashboard**: Disable draggable panels on small devices. [#20629](https://github.com/grafana/grafana/pull/20629), [@peterholmberg](https://github.com/peterholmberg) +- **DataLinks**: Links with \${\_\_value.time} do not work when clicking on first result . [#20019](https://github.com/grafana/grafana/pull/20019), [@dweineha](https://github.com/dweineha) +- **Explore**: Fix showing of results in selected timezone (UTC/local). [#20812](https://github.com/grafana/grafana/pull/20812), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix timepicker when browsing back after switching datasource. [#21454](https://github.com/grafana/grafana/pull/21454), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Sync timepicker and logs after live-tailing stops. [#20979](https://github.com/grafana/grafana/pull/20979), [@ivanahuckova](https://github.com/ivanahuckova) +- **Graph**: Fix when clicking a plot on a touch device we won't display the annotation menu. [#21479](https://github.com/grafana/grafana/pull/21479), [@mckn](https://github.com/mckn) +- **OAuth**: Fix role mapping from id token. [#20300](https://github.com/grafana/grafana/pull/20300), [@seanson](https://github.com/seanson) +- **Plugins**: Add appSubUrl string to config pages. [#21414](https://github.com/grafana/grafana/pull/21414), [@Maddin-619](https://github.com/Maddin-619) +- **Provisioning**: Start provision dashboards after Grafana server have started. [#21564](https://github.com/grafana/grafana/pull/21564), [@marefr](https://github.com/marefr) +- **Render**: Use https as protocol when rendering if HTTP2 enabled. [#21600](https://github.com/grafana/grafana/pull/21600), [@marefr](https://github.com/marefr) +- **Security**: Use same cookie settings for all cookies. [#19787](https://github.com/grafana/grafana/pull/19787), [@jeffdesc](https://github.com/jeffdesc) +- **Singlestat**: Support empty value map texts. [#20952](https://github.com/grafana/grafana/pull/20952), [@hendrikvh](https://github.com/hendrikvh) +- **Units**: Custom suffix and prefix units can now be specified, for example custom currency & SI & time formats. [#20763](https://github.com/grafana/grafana/pull/20763), [@ryantxu](https://github.com/ryantxu) +- **grafana/ui**: Do not build grafana/ui in strict mode as it depends on non-strict libs. [#21319](https://github.com/grafana/grafana/pull/21319), [@dprokop](https://github.com/dprokop) + +# 6.5.3 (2020-01-15) + +### Features / Enhancements + +- **API**: Validate redirect_to cookie has valid (Grafana) url . [#21057](https://github.com/grafana/grafana/pull/21057), [@papagian](https://github.com/papagian), Thanks Habi S Ravi for reporting this issue. + +### Bug Fixes + +- **AdHocFilter**: Shows SubMenu when filtering directly from table. [#21017](https://github.com/grafana/grafana/pull/21017), [@hugohaggmark](https://github.com/hugohaggmark) +- **Cloudwatch**: Fixed crash when switching from cloudwatch data source. [#21376](https://github.com/grafana/grafana/pull/21376), [@torkelo](https://github.com/torkelo) +- **DataLinks**: Sanitize data/panel link URLs. [#21140](https://github.com/grafana/grafana/pull/21140), [@dprokop](https://github.com/dprokop) +- **Elastic**: Fix multiselect variable interpolation for logs. [#20894](https://github.com/grafana/grafana/pull/20894), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Fixes so user can change HTTP Method in config settings. [#21055](https://github.com/grafana/grafana/pull/21055), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: Prevents validation of inputs when clicking in them without changing the value. [#21059](https://github.com/grafana/grafana/pull/21059), [@hugohaggmark](https://github.com/hugohaggmark) +- **Rendering**: Fix panel PNG rendering when using sub url & serve_from_sub_path = true. [#21306](https://github.com/grafana/grafana/pull/21306), [@bgranvea](https://github.com/bgranvea) +- **Table**: Matches column names with unescaped regex characters. [#21164](https://github.com/grafana/grafana/pull/21164), [@hugohaggmark](https://github.com/hugohaggmark) + +# 6.5.2 (2019-12-11) + +### Bug Fixes + +- **Alerting**: Improve alert threshold handle dragging behavior. [#20922](https://github.com/grafana/grafana/pull/20922), [@torkelo](https://github.com/torkelo) +- **AngularPanels**: Fixed loading spinner being stuck in some rare cases. [#20878](https://github.com/grafana/grafana/pull/20878), [@torkelo](https://github.com/torkelo) +- **CloudWatch**: Fix query editor does not render in Explore. [#20909](https://github.com/grafana/grafana/pull/20909), [@davkal](https://github.com/davkal) +- **CloudWatch**: Remove illegal character escaping in inferred expressions. [#20915](https://github.com/grafana/grafana/pull/20915), [@sunker](https://github.com/sunker) +- **CloudWatch**: Remove template variable error message. [#20864](https://github.com/grafana/grafana/pull/20864), [@sunker](https://github.com/sunker) +- **CloudWatch**: Use datasource template variable in curated dashboards. [#20917](https://github.com/grafana/grafana/pull/20917), [@sunker](https://github.com/sunker) +- **Elasticsearch**: Set default port to 9200 in ConfigEditor. [#20948](https://github.com/grafana/grafana/pull/20948), [@papagian](https://github.com/papagian) +- **Gauge/BarGauge**: Added support for value mapping of "no data"-state to text/value. [#20842](https://github.com/grafana/grafana/pull/20842), [@mckn](https://github.com/mckn) +- **Graph**: Prevent tooltip from being displayed outside of window. [#20874](https://github.com/grafana/grafana/pull/20874), [@mckn](https://github.com/mckn) +- **Graphite**: Fixes error with annotation metric queries . [#20857](https://github.com/grafana/grafana/pull/20857), [@dprokop](https://github.com/dprokop) +- **Login**: Fix fatal error when navigating from reset password page. [#20747](https://github.com/grafana/grafana/pull/20747), [@peterholmberg](https://github.com/peterholmberg) +- **MixedDatasources**: Do not filter out all mixed data sources in add mixed query dropdown. [#20990](https://github.com/grafana/grafana/pull/20990), [@torkelo](https://github.com/torkelo) +- **Prometheus**: Fix caching for default labels request. [#20718](https://github.com/grafana/grafana/pull/20718), [@aocenas](https://github.com/aocenas) +- **Prometheus**: Run default labels query only once. [#20898](https://github.com/grafana/grafana/pull/20898), [@aocenas](https://github.com/aocenas) +- **Security**: Fix invite link still accessible after completion or revocation. [#20863](https://github.com/grafana/grafana/pull/20863), [@aknuds1](https://github.com/aknuds1) +- **Server**: Fail when unable to create log directory. [#20804](https://github.com/grafana/grafana/pull/20804), [@aknuds1](https://github.com/aknuds1) +- **TeamPicker**: Increase size limit from 10 to 100. [#20882](https://github.com/grafana/grafana/pull/20882), [@hendrikvh](https://github.com/hendrikvh) +- **Units**: Remove SI prefix symbol from new milli/microSievert(/h) units. [#20650](https://github.com/grafana/grafana/pull/20650), [@zegelin](https://github.com/zegelin) + +# 6.5.1 (2019-11-28) + +### Bug Fixes + +- **CloudWatch**: Region template query fix. [#20661](https://github.com/grafana/grafana/pull/20661), [@sunker](https://github.com/sunker) +- **CloudWatch**: Fix annotations query editor loading. [#20687](https://github.com/grafana/grafana/pull/20687), [@sunker](https://github.com/sunker) +- **Panel**: Fixes undefined services/dependencies in plugins without `/**@ngInject*/`. [#20696](https://github.com/grafana/grafana/pull/20696), [@hugohaggmark](https://github.com/hugohaggmark) +- **Server**: Fix failure to start with "bind: address already in use" when using socket as protocol. [#20679](https://github.com/grafana/grafana/pull/20679), [@aknuds1](https://github.com/aknuds1) +- **Stats**: Fix active admins/editors/viewers stats are counted more than once if the user is part of more than one org. [#20711](https://github.com/grafana/grafana/pull/20711), [@papagian](https://github.com/papagian) + +# 6.5.0 (2019-11-25) + +### Features / Enhancements + +- **CloudWatch**: Add curated dashboards for most popular amazon services. [#20486](https://github.com/grafana/grafana/pull/20486), [@sunker](https://github.com/sunker) +- **CloudWatch**: Enable Min time interval. [#20260](https://github.com/grafana/grafana/pull/20260), [@mtanda](https://github.com/mtanda) +- **Explore**: UI improvements for log details. [#20485](https://github.com/grafana/grafana/pull/20485), [@ivanahuckova](https://github.com/ivanahuckova) +- **Server**: Improve grafana-server diagnostics configuration for profiling and tracing. [#20593](https://github.com/grafana/grafana/pull/20593), [@papagian](https://github.com/papagian) + +### Bug Fixes + +- **BarGauge/Gauge**: Add back missing title option field display options. [#20616](https://github.com/grafana/grafana/pull/20616), [@torkelo](https://github.com/torkelo) +- **CloudWatch**: Fix high CPU load. [#20579](https://github.com/grafana/grafana/pull/20579), [@marefr](https://github.com/marefr) +- **CloudWatch**: Fix high resolution mode without expression. [#20459](https://github.com/grafana/grafana/pull/20459), [@mtanda](https://github.com/mtanda) +- **CloudWatch**: Make sure period variable is being interpreted correctly. [#20447](https://github.com/grafana/grafana/pull/20447), [@sunker](https://github.com/sunker) +- **CloudWatch**: Remove HighResolution toggle since it's not being used. [#20440](https://github.com/grafana/grafana/pull/20440), [@sunker](https://github.com/sunker) +- **Cloudwatch**: Fix LaunchTime attribute tag bug. [#20237](https://github.com/grafana/grafana/pull/20237), [@sunker](https://github.com/sunker) +- **Data links**: Fix URL field turns read-only for graph panels. [#20381](https://github.com/grafana/grafana/pull/20381), [@dprokop](https://github.com/dprokop) +- **Explore**: Keep logQL filters when selecting labels in log row details. [#20570](https://github.com/grafana/grafana/pull/20570), [@ivanahuckova](https://github.com/ivanahuckova) +- **MySQL**: Fix TLS auth settings in config page. [#20501](https://github.com/grafana/grafana/pull/20501), [@peterholmberg](https://github.com/peterholmberg) +- **Provisioning**: Fix unmarshaling nested jsonData values. [#20399](https://github.com/grafana/grafana/pull/20399), [@aocenas](https://github.com/aocenas) +- **Server**: Should fail when server is unable to bind port. [#20409](https://github.com/grafana/grafana/pull/20409), [@aknuds1](https://github.com/aknuds1) +- **Templating**: Prevents crash when \$\_\_searchFilter is not a string. [#20526](https://github.com/grafana/grafana/pull/20526), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Fixes issue with template variable value not properly html escaped [#20588](https://github.com/grafana/grafana/pull/20588), [@torkelo](https://github.com/torkelo) +- **TimePicker**: Should update after location change. [#20466](https://github.com/grafana/grafana/pull/20466), [@torkelo](https://github.com/torkelo) + +## Breaking changes + +- **CloudWatch**: Pre Grafana 6.5.0, the CloudWatch datasource used the GetMetricStatistics API for all queries that did not have an ´id´ and did not have an ´expression´ defined in the query editor. The GetMetricStatistics API has a limit of 400 transactions per second. In this release, all queries use the GetMetricData API. The GetMetricData API has a limit of 50 transactions per second and 100 metrics per transaction. For API pricing information, please refer to the CloudWatch pricing page (https://aws.amazon.com/cloudwatch/pricing/). + +- **CloudWatch**: The GetMetricData API does not return metric unit, so unit auto detection in panels is no longer supported. + +- **CloudWatch**: The `HighRes` switch has been removed from the query editor. Read more about this in [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5). + +- **CloudWatch**: In previous versions of Grafana, there was partial support for using multi-valued template variables as dimension values. When a multi-valued template variable is being used for dimension values in Grafana 6.5, a [search expression](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-search-expressions.html) will be generated. In the GetMetricData API, expressions are limited to 1024 characters, so you might reach this limit if you are using a large number of values. Read our [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5) guide to see how you can use the `*` wildcard for this use case. + +# 6.5.0-beta1 (2019-11-14) + +### Features / Enhancements + +- **API**: Add `createdAt` and `updatedAt` to api/users/lookup. [#19496](https://github.com/grafana/grafana/pull/19496), [@gotjosh](https://github.com/gotjosh) +- **API**: Add createdAt field to /api/users/:id. [#19475](https://github.com/grafana/grafana/pull/19475), [@cored](https://github.com/cored) +- **Admin**: Adds setting to disable creating initial admin user. [#19505](https://github.com/grafana/grafana/pull/19505), [@shavonn](https://github.com/shavonn) +- **Alerting**: Include alert_state in Kafka notifier payload. [#20099](https://github.com/grafana/grafana/pull/20099), [@arnaudlemaignen](https://github.com/arnaudlemaignen) +- **AuthProxy**: Can now login with auth proxy and get a login token. [#20175](https://github.com/grafana/grafana/pull/20175), [@torkelo](https://github.com/torkelo) +- **AuthProxy**: replaces setting ldap_sync_ttl with sync_ttl. [#20191](https://github.com/grafana/grafana/pull/20191), [@jongyllen](https://github.com/jongyllen) +- **AzureMonitor**: Alerting for Azure Application Insights. [#19381](https://github.com/grafana/grafana/pull/19381), [@ChadNedzlek](https://github.com/ChadNedzlek) +- **Build**: Upgrade to Go 1.13. [#19502](https://github.com/grafana/grafana/pull/19502), [@aknuds1](https://github.com/aknuds1) +- **CLI**: Reduce memory usage for plugin installation. [#19639](https://github.com/grafana/grafana/pull/19639), [@olivierlemasle](https://github.com/olivierlemasle) +- **CloudWatch**: Add ap-east-1 to hard-coded region lists. [#19523](https://github.com/grafana/grafana/pull/19523), [@Nessworthy](https://github.com/Nessworthy) +- **CloudWatch**: ContainerInsights metrics support. [#18971](https://github.com/grafana/grafana/pull/18971), [@francopeapea](https://github.com/francopeapea) +- **CloudWatch**: Support dynamic queries using dimension wildcards [#20058](https://github.com/grafana/grafana/issues/20058), [@sunker](https://github.com/sunker) +- **CloudWatch**: Stop using GetMetricStatistics and use GetMetricData for all time series requests [#20057](https://github.com/grafana/grafana/issues/20057), [@sunker](https://github.com/sunker) +- **CloudWatch**: Convert query editor from Angular to React [#19880](https://github.com/grafana/grafana/issues/19880), [@sunker](https://github.com/sunker) +- **CloudWatch**: Convert config editor from Angular to React [#19881](https://github.com/grafana/grafana/issues/19881), [@shavonn](https://github.com/shavonn) +- **CloudWatch**: Improved error handling when throttling occurs [#20348](https://github.com/grafana/grafana/issues/20348), [@sunker](https://github.com/sunker) +- **CloudWatch**: Deep linking from Grafana panel to CloudWatch console [#20279](https://github.com/grafana/grafana/issues/20279), [@sunker](https://github.com/sunker) +- **CloudWatch**: Add Grafana user agent to GMD calls [#20277](https://github.com/grafana/grafana/issues/20277), [@sunker](https://github.com/sunker) +- **Dashboard**: Allows the d-solo route to be used without slug. [#19640](https://github.com/grafana/grafana/pull/19640), [@97amarnathk](https://github.com/97amarnathk) +- **Docker**: Build and publish an additional Ubuntu based docker image. [#20196](https://github.com/grafana/grafana/pull/20196), [@aknuds1](https://github.com/aknuds1) +- **Elasticsearch**: Adds support for region annotations. [#17602](https://github.com/grafana/grafana/pull/17602), [@fangel](https://github.com/fangel) +- **Explore**: Add custom DataLinks on datasource level (like tracing links). [#20060](https://github.com/grafana/grafana/pull/20060), [@aocenas](https://github.com/aocenas) +- **Explore**: Add functionality to show/hide query row results. [#19794](https://github.com/grafana/grafana/pull/19794), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Synchronise time ranges in split mode. [#19274](https://github.com/grafana/grafana/pull/19274), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: UI change for log row details . [#20034](https://github.com/grafana/grafana/pull/20034), [@ivanahuckova](https://github.com/ivanahuckova) +- **Frontend**: Migrate DataSource HTTP Settings to React. [#19452](https://github.com/grafana/grafana/pull/19452), [@dprokop](https://github.com/dprokop) +- **Frontend**: Show browser not supported notification. [#19904](https://github.com/grafana/grafana/pull/19904), [@peterholmberg](https://github.com/peterholmberg) +- **Graph**: Added series override option to have hidden series be persisted on save. [#20124](https://github.com/grafana/grafana/pull/20124), [@Gauravshah](https://github.com/Gauravshah) +- **Graphite**: Add Metrictank option to settings to view Metrictank request processing info in new inspect feature. [#20138](https://github.com/grafana/grafana/pull/20138), [@ryantxu](https://github.com/ryantxu) +- **LDAP**: Enable single user sync. [#19446](https://github.com/grafana/grafana/pull/19446), [@gotjosh](https://github.com/gotjosh) +- **LDAP**: Last org admin can login but wont be removed. [#20326](https://github.com/grafana/grafana/pull/20326), [@xlson](https://github.com/xlson) +- **LDAP**: Support env variable expressions in ldap.toml file. [#20173](https://github.com/grafana/grafana/pull/20173), [@torkelo](https://github.com/torkelo) +- **OAuth**: Generic OAuth role mapping support. [#17149](https://github.com/grafana/grafana/pull/17149), [@hypery2k](https://github.com/hypery2k) +- **Prometheus**: Custom query parameters string for Thanos downsampling. [#19121](https://github.com/grafana/grafana/pull/19121), [@seuf](https://github.com/seuf) +- **Provisioning**: Allow saving of provisioned dashboards. [#19820](https://github.com/grafana/grafana/pull/19820), [@jongyllen](https://github.com/jongyllen) +- **Security**: Minor XSS issue resolved by angularjs upgrade from 1.6.6 -> 1.6.9. [#19849](https://github.com/grafana/grafana/pull/19849), [@peterholmberg](https://github.com/peterholmberg) +- **TablePanel**: Prevents crash when data contains mixed data formats. [#20202](https://github.com/grafana/grafana/pull/20202), [@hugohaggmark](https://github.com/hugohaggmark) +- **Templating**: Introduces \$\_\_searchFilter to Query Variables. [#19858](https://github.com/grafana/grafana/pull/19858), [@hugohaggmark](https://github.com/hugohaggmark) +- **Templating**: Made default template variable query editor field a textarea with automatic height. [#20288](https://github.com/grafana/grafana/pull/20288), [@torkelo](https://github.com/torkelo) +- **Units**: Add milli/microSievert, milli/microSievert/h and pixels. [#20144](https://github.com/grafana/grafana/pull/20144), [@ryantxu](https://github.com/ryantxu) +- **Units**: Added mega ampere and watt-hour per kg. [#19922](https://github.com/grafana/grafana/pull/19922), [@Karan96Kaushik](https://github.com/Karan96Kaushik) +- **Enterprise**: Enterprise without a license behaves like OSS (Enterprise) + +### Bug Fixes + +- **API**: Added dashboardId and slug in response to dashboard import api. [#19692](https://github.com/grafana/grafana/pull/19692), [@jongyllen](https://github.com/jongyllen) +- **API**: Fix logging of dynamic listening port. [#19644](https://github.com/grafana/grafana/pull/19644), [@oleggator](https://github.com/oleggator) +- **BarGauge**: Fix so that default thresholds not keeps resetting. [#20190](https://github.com/grafana/grafana/pull/20190), [@lzdw](https://github.com/lzdw) +- **CloudWatch**: Fix incorrect casing of Redshift dimension entry for service class and stage. [#19897](https://github.com/grafana/grafana/pull/19897), [@nlsdfnbch](https://github.com/nlsdfnbch) +- **CloudWatch**: Fixing AWS Kafka dimension names. [#19986](https://github.com/grafana/grafana/pull/19986), [@skuxy](https://github.com/skuxy) +- **CloudWatch**: Metric math broken when using multi template variables [#18337](https://github.com/grafana/grafana/issues/18337), [@sunker](https://github.com/sunker) +- **CloudWatch**: Graphs with multiple multi-value dimension variables don't work [#17949](https://github.com/grafana/grafana/issues/17949), [@sunker](https://github.com/sunker) +- **CloudWatch**: Variables' values surrounded with braces in request sent to AWS [#14451](https://github.com/grafana/grafana/issues/14451), [@sunker](https://github.com/sunker) +- **CloudWatch**: Cloudwatch Query for a list of instances for which data is available in the selected time interval [#12784](https://github.com/grafana/grafana/issues/12784), [@sunker](https://github.com/sunker) +- **CloudWatch**: Dimension's positioning/order should be stored in the json dashboard [#11062](https://github.com/grafana/grafana/issues/11062), [@sunker](https://github.com/sunker) +- **CloudWatch**: Batch CloudWatch API call support in backend [#7991](https://github.com/grafana/grafana/issues/7991), [@sunker](https://github.com/sunker) +- **ColorPicker**: Fixes issue with ColorPicker disappearing too quickly . [#20289](https://github.com/grafana/grafana/pull/20289), [@dprokop](https://github.com/dprokop) +- **Datasource**: Add custom headers on alerting queries. [#19508](https://github.com/grafana/grafana/pull/19508), [@weeco](https://github.com/weeco) +- **Docker**: Add additional glibc dependencies to support certain backend plugins in alpine. [#20214](https://github.com/grafana/grafana/pull/20214), [@briangann](https://github.com/briangann) +- **Docker**: Build and use musl-based binaries in alpine images to resolve glibc incompatibility issues. [#19798](https://github.com/grafana/grafana/pull/19798), [@aknuds1](https://github.com/aknuds1) +- **Elasticsearch**: Fix template variables interpolation when redirecting to Explore. [#20314](https://github.com/grafana/grafana/pull/20314), [@ivanahuckova](https://github.com/ivanahuckova) +- **Elasticsearch**: Support rendering in logs panel. [#20229](https://github.com/grafana/grafana/pull/20229), [@davkal](https://github.com/davkal) +- **Explore**: Expand template variables when redirecting from dashboard panel. [#19582](https://github.com/grafana/grafana/pull/19582), [@ivanahuckova](https://github.com/ivanahuckova) +- **OAuth**: Make the login button display name of custom OAuth provider. [#20209](https://github.com/grafana/grafana/pull/20209), [@dprokop](https://github.com/dprokop) +- **ReactPanels**: Adds Explore menu item. [#20236](https://github.com/grafana/grafana/pull/20236), [@hugohaggmark](https://github.com/hugohaggmark) +- **Team Sync**: Fix URL encode Group IDs for external team sync. [#20280](https://github.com/grafana/grafana/pull/20280), [@gotjosh](https://github.com/gotjosh) + +## Breaking changes + +- **CloudWatch**: Pre Grafana 6.5.0, the CloudWatch datasource used the GetMetricStatistics API for all queries that did not have an ´id´ and did not have an ´expression´ defined in the query editor. The GetMetricStatistics API has a limit of 400 transactions per second. In this release, all queries use the GetMetricData API. The GetMetricData API has a limit of 50 transactions per second and 100 metrics per transaction. For API pricing information, please refer to the CloudWatch pricing page (https://aws.amazon.com/cloudwatch/pricing/). + +- **CloudWatch**: The GetMetricData API does not return metric unit, so unit auto detection in panels is no longer supported. + +- **CloudWatch**: The `HighRes` switch has been removed from the query editor. Read more about this in [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5). + +- **CloudWatch**: In previous versions of Grafana, there was partial support for using multi-valued template variables as dimension values. When a multi-valued template variable is being used for dimension values in Grafana 6.5, a [search expression](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-search-expressions.html) will be generated. In the GetMetricData API, expressions are limited to 1024 characters, so you might reach this limit if you are using a large number of values. Read our [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5) guide to see how you can use the `*` wildcard for this use case. + +# 6.4.5 (2019-11-25) + +### Bug Fixes + +- **CloudWatch**: Fix high CPU load [#20579](https://github.com/grafana/grafana/pull/20579) + +# 6.4.4 (2019-11-06) + +### Bug Fixes + +- **MySQL**: Fix encoding in connection string [#20192](https://github.com/grafana/grafana/pull/20192) +- **DataLinks**: Fix blur issues. [#19883](https://github.com/grafana/grafana/pull/19883), [@aocenas](https://github.com/aocenas) +- **Docker**: Makes it possible to parse timezones in the docker image. [#20081](https://github.com/grafana/grafana/pull/20081), [@xlson](https://github.com/xlson) +- **LDAP**: All LDAP servers should be tried even if one of them returns a connection error. [#20077](https://github.com/grafana/grafana/pull/20077), [@jongyllen](https://github.com/jongyllen) +- **LDAP**: No longer shows incorrectly matching groups based on role in debug page. [#20018](https://github.com/grafana/grafana/pull/20018), [@xlson](https://github.com/xlson) +- **Singlestat**: Fix no data / null value mapping . [#19951](https://github.com/grafana/grafana/pull/19951), [@ryantxu](https://github.com/ryantxu) + +#### Security vulnerability + +The MySQL data source connection string fix, [#20192](https://github.com/grafana/grafana/pull/20192), that was part of this release +also fixed a security vulnerability. Thanks Yuriy Dyachenko for discovering and notifying us about this. + +# 6.4.3 (2019-10-16) + +### Bug Fixes + +- **Alerting**: All notification channels should send even if one fails to send. [#19807](https://github.com/grafana/grafana/pull/19807), [@jan25](https://github.com/jan25) +- **AzureMonitor**: Fix slate interference with dropdowns. [#19799](https://github.com/grafana/grafana/pull/19799), [@aocenas](https://github.com/aocenas) +- **ContextMenu**: make ContextMenu positioning aware of the viewport width. [#19699](https://github.com/grafana/grafana/pull/19699), [@krvajal](https://github.com/krvajal) +- **DataLinks**: Fix context menu not showing in singlestat-ish visualisations. [#19809](https://github.com/grafana/grafana/pull/19809), [@dprokop](https://github.com/dprokop) +- **DataLinks**: Fix url field not releasing focus. [#19804](https://github.com/grafana/grafana/pull/19804), [@aocenas](https://github.com/aocenas) +- **Datasource**: Fixes clicking outside of some query editors required 2 clicks. [#19822](https://github.com/grafana/grafana/pull/19822), [@aocenas](https://github.com/aocenas) +- **Panels**: Fixes default tab for visualizations without Queries Tab. [#19803](https://github.com/grafana/grafana/pull/19803), [@hugohaggmark](https://github.com/hugohaggmark) +- **Singlestat**: Fixed issue with mapping null to text. [#19689](https://github.com/grafana/grafana/pull/19689), [@torkelo](https://github.com/torkelo) +- **@grafana/toolkit**: Don't fail plugin creation when git user.name config is not set. [#19821](https://github.com/grafana/grafana/pull/19821), [@dprokop](https://github.com/dprokop) +- **@grafana/toolkit**: TSLint line number off by 1. [#19782](https://github.com/grafana/grafana/pull/19782), [@fredwangwang](https://github.com/fredwangwang) + +# 6.4.2 (2019-10-08) + +### Bug Fixes + +- **CloudWatch**: Changes incorrect dimension wmlid to wlmid . [#19679](https://github.com/grafana/grafana/pull/19679), [@ATTron](https://github.com/ATTron) +- **Grafana Image Renderer**: Fixes plugin page. [#19664](https://github.com/grafana/grafana/pull/19664), [@hugohaggmark](https://github.com/hugohaggmark) +- **Graph**: Fixes auto decimals logic for y axis ticks that results in too many decimals for high values. [#19618](https://github.com/grafana/grafana/pull/19618), [@torkelo](https://github.com/torkelo) +- **Graph**: Switching to series mode should re-render graph. [#19623](https://github.com/grafana/grafana/pull/19623), [@torkelo](https://github.com/torkelo) +- **Loki**: Fix autocomplete on label values. [#19579](https://github.com/grafana/grafana/pull/19579), [@aocenas](https://github.com/aocenas) +- **Loki**: Removes live option for logs panel. [#19533](https://github.com/grafana/grafana/pull/19533), [@davkal](https://github.com/davkal) +- **Profile**: Fix issue with user profile not showing more than sessions sessions in some cases. [#19578](https://github.com/grafana/grafana/pull/19578), [@huynhsamha](https://github.com/huynhsamha) +- **Prometheus**: Fixes so results in Panel always are sorted by query order. [#19597](https://github.com/grafana/grafana/pull/19597), [@hugohaggmark](https://github.com/hugohaggmark) +- **ShareQuery**: Fixed issue when using -- Dashboard -- datasource (to share query result) when dashboard had rows. [#19610](https://github.com/grafana/grafana/pull/19610), [@torkelo](https://github.com/torkelo) +- **Show SAML login button if SAML is enabled**. [#19591](https://github.com/grafana/grafana/pull/19591), [@papagian](https://github.com/papagian) +- **SingleStat**: Fixes \$\_\_name postfix/prefix usage. [#19687](https://github.com/grafana/grafana/pull/19687), [@hugohaggmark](https://github.com/hugohaggmark) +- **Table**: Proper handling of json data with dataframes. [#19596](https://github.com/grafana/grafana/pull/19596), [@marefr](https://github.com/marefr) +- **Units**: Fixed wrong id for Terabits/sec. [#19611](https://github.com/grafana/grafana/pull/19611), [@andreaslangnevyjel](https://github.com/andreaslangnevyjel) + +# 6.4.1 (2019-10-02) + +### Bug Fixes + +- **Provisioning**: Fixed issue where empty nested keys in YAML provisioning caused server crash, [#19547](https://github.com/grafana/grafana/pull/19547) +- **ImageRendering**: Fixed issue with image rendering in enterprise build (Enterprise) +- **Reporting**: Fixed issue with reporting service when STMP disabled (Enterprise). + +# 6.4.0 (2019-10-01) + +### Features / Enhancements + +- **Build**: Upgrade go to 1.12.10. [#19499](https://github.com/grafana/grafana/pull/19499), [@marefr](https://github.com/marefr) +- **DataLinks**: Suggestions menu improvements. [#19396](https://github.com/grafana/grafana/pull/19396), [@dprokop](https://github.com/dprokop) +- **Explore**: Take root_url setting into account when redirecting from dashboard to explore. [#19447](https://github.com/grafana/grafana/pull/19447), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Update broken link to logql docs. [#19510](https://github.com/grafana/grafana/pull/19510), [@ivanahuckova](https://github.com/ivanahuckova) +- **Logs**: Adds Logs Panel as a visualization. [#19504](https://github.com/grafana/grafana/pull/19504), [@davkal](https://github.com/davkal) +- **Reporting**: Generate and email PDF reports based on Dashboards (Enterprise) + +### Bug Fixes + +- **CLI**: Fix version selection for plugin install. [#19498](https://github.com/grafana/grafana/pull/19498), [@aocenas](https://github.com/aocenas) +- **Graph**: Fixes minor issue with series override color picker and custom color . [#19516](https://github.com/grafana/grafana/pull/19516), [@torkelo](https://github.com/torkelo) + +## Plugins that need updating when upgrading from 6.3 to 6.4 + +- [Splunk](https://grafana.com/grafana/plugins/grafana-splunk-datasource) + +# 6.4.0-beta2 (2019-09-25) + +### Features / Enhancements + +- **Azure Monitor**: Remove support for cross resource queries (#19115)". [#19346](https://github.com/grafana/grafana/pull/19346), [@sunker](https://github.com/sunker) +- **Docker**: Upgrade packages to resolve reported vulnerabilities. [#19188](https://github.com/grafana/grafana/pull/19188), [@marefr](https://github.com/marefr) +- **Graphite**: Time range expansion reduced from 1 minute to 1 second. [#19246](https://github.com/grafana/grafana/pull/19246), [@torkelo](https://github.com/torkelo) +- **grafana/toolkit**: Add plugin creation task. [#19207](https://github.com/grafana/grafana/pull/19207), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **Alerting**: Prevents creating alerts from unsupported queries. [#19250](https://github.com/grafana/grafana/pull/19250), [@hugohaggmark](https://github.com/hugohaggmark) +- **Alerting**: Truncate PagerDuty summary when greater than 1024 characters. [#18730](https://github.com/grafana/grafana/pull/18730), [@nvllsvm](https://github.com/nvllsvm) +- **Cloudwatch**: Fix autocomplete for Gamelift dimensions. [#19146](https://github.com/grafana/grafana/pull/19146), [@kevinpz](https://github.com/kevinpz) +- **Dashboard**: Fix export for sharing when panels use default data source. [#19315](https://github.com/grafana/grafana/pull/19315), [@torkelo](https://github.com/torkelo) +- **Database**: Rewrite system statistics query to perform better. [#19178](https://github.com/grafana/grafana/pull/19178), [@papagian](https://github.com/papagian) +- **Gauge/BarGauge**: Fix issue with [object Object] in titles . [#19217](https://github.com/grafana/grafana/pull/19217), [@ryantxu](https://github.com/ryantxu) +- **MSSQL**: Revert usage of new connectionstring format introduced by #18384. [#19203](https://github.com/grafana/grafana/pull/19203), [@marefr](https://github.com/marefr) +- **Multi-LDAP**: Do not fail-fast on invalid credentials. [#19261](https://github.com/grafana/grafana/pull/19261), [@gotjosh](https://github.com/gotjosh) +- **MySQL, Postgres, MSSQL**: Fix validating query with template variables in alert . [#19237](https://github.com/grafana/grafana/pull/19237), [@marefr](https://github.com/marefr) +- **MySQL, Postgres**: Update raw sql when query builder updates. [#19209](https://github.com/grafana/grafana/pull/19209), [@marefr](https://github.com/marefr) +- **MySQL**: Limit datasource error details returned from the backend. [#19373](https://github.com/grafana/grafana/pull/19373), [@marefr](https://github.com/marefr) + +# 6.4.0-beta1 (2019-09-17) + +### Features / Enhancements + +- **Reporting**: Created scheduled PDF reports for any dashboard (Enterprise). +- **API**: Readonly datasources should not be created via the API. [#19006](https://github.com/grafana/grafana/pull/19006), [@papagian](https://github.com/papagian) +- **Alerting**: Include configured AlertRuleTags in Webhooks notifier. [#18233](https://github.com/grafana/grafana/pull/18233), [@dominic-miglar](https://github.com/dominic-miglar) +- **Annotations**: Add annotations support to Loki. [#18949](https://github.com/grafana/grafana/pull/18949), [@aocenas](https://github.com/aocenas) +- **Annotations**: Use a single row to represent a region. [#17673](https://github.com/grafana/grafana/pull/17673), [@ryantxu](https://github.com/ryantxu) +- **Auth**: Allow inviting existing users when login form is disabled. [#19048](https://github.com/grafana/grafana/pull/19048), [@548017](https://github.com/548017) +- **Azure Monitor**: Add support for cross resource queries. [#19115](https://github.com/grafana/grafana/pull/19115), [@sunker](https://github.com/sunker) +- **CLI**: Allow installing custom binary plugins. [#17551](https://github.com/grafana/grafana/pull/17551), [@aocenas](https://github.com/aocenas) +- **Dashboard**: Adds Logs Panel (alpha) as visualization option for Dashboards. [#18641](https://github.com/grafana/grafana/pull/18641), [@hugohaggmark](https://github.com/hugohaggmark) +- **Dashboard**: Reuse query results between panels . [#16660](https://github.com/grafana/grafana/pull/16660), [@ryantxu](https://github.com/ryantxu) +- **Dashboard**: Set time to to 23:59:59 when setting To time using calendar. [#18595](https://github.com/grafana/grafana/pull/18595), [@simPod](https://github.com/simPod) +- **DataLinks**: Add DataLinks support to Gauge, BarGauge and stat panel. [#18605](https://github.com/grafana/grafana/pull/18605), [@ryantxu](https://github.com/ryantxu) +- **DataLinks**: Enable access to labels & field names. [#18918](https://github.com/grafana/grafana/pull/18918), [@torkelo](https://github.com/torkelo) +- **DataLinks**: Enable multiple data links per panel. [#18434](https://github.com/grafana/grafana/pull/18434), [@dprokop](https://github.com/dprokop) +- **Docker**: switch docker image to alpine base with phantomjs support. [#18468](https://github.com/grafana/grafana/pull/18468), [@DanCech](https://github.com/DanCech) +- **Elasticsearch**: allow templating queries to order by doc_count. [#18870](https://github.com/grafana/grafana/pull/18870), [@hackery](https://github.com/hackery) +- **Explore**: Add throttling when doing live queries. [#19085](https://github.com/grafana/grafana/pull/19085), [@aocenas](https://github.com/aocenas) +- **Explore**: Adds ability to go back to dashboard, optionally with query changes. [#17982](https://github.com/grafana/grafana/pull/17982), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Reduce default time range to last hour. [#18212](https://github.com/grafana/grafana/pull/18212), [@davkal](https://github.com/davkal) +- **Gauge/BarGauge**: Support decimals for min/max. [#18368](https://github.com/grafana/grafana/pull/18368), [@ryantxu](https://github.com/ryantxu) +- **Graph**: New series override transform constant that renders a single point as a line across the whole graph. [#19102](https://github.com/grafana/grafana/pull/19102), [@davkal](https://github.com/davkal) +- **Image rendering**: Add deprecation warning when PhantomJS is used for rendering images. [#18933](https://github.com/grafana/grafana/pull/18933), [@papagian](https://github.com/papagian) +- **InfluxDB**: Enable interpolation within ad-hoc filter values. [#18077](https://github.com/grafana/grafana/pull/18077), [@kvc-code](https://github.com/kvc-code) +- **LDAP**: Allow an user to be synchronized against LDAP. [#18976](https://github.com/grafana/grafana/pull/18976), [@gotjosh](https://github.com/gotjosh) +- **Ldap**: Add ldap debug page. [#18759](https://github.com/grafana/grafana/pull/18759), [@peterholmberg](https://github.com/peterholmberg) +- **Loki**: Remove prefetching of default label values. [#18213](https://github.com/grafana/grafana/pull/18213), [@davkal](https://github.com/davkal) +- **Metrics**: Add failed alert notifications metric. [#18089](https://github.com/grafana/grafana/pull/18089), [@koorgoo](https://github.com/koorgoo) +- **OAuth**: Support JMES path lookup when retrieving user email. [#14683](https://github.com/grafana/grafana/pull/14683), [@bobmshannon](https://github.com/bobmshannon) +- **OAuth**: return GitLab groups as a part of user info (enable team sync). [#18388](https://github.com/grafana/grafana/pull/18388), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Panels**: Add unit for electrical charge - ampere-hour. [#18950](https://github.com/grafana/grafana/pull/18950), [@anirudh-ramesh](https://github.com/anirudh-ramesh) +- **Plugin**: AzureMonitor - Reapply MetricNamespace support. [#17282](https://github.com/grafana/grafana/pull/17282), [@raphaelquati](https://github.com/raphaelquati) +- **Plugins**: better warning when plugins fail to load. [#18671](https://github.com/grafana/grafana/pull/18671), [@ryantxu](https://github.com/ryantxu) +- **Postgres**: Add support for scram sha 256 authentication. [#18397](https://github.com/grafana/grafana/pull/18397), [@nonamef](https://github.com/nonamef) +- **RemoteCache**: Support SSL with Redis. [#18511](https://github.com/grafana/grafana/pull/18511), [@kylebrandt](https://github.com/kylebrandt) +- **SingleStat**: The gauge option in now disabled/hidden (unless it's an old panel with it already enabled) . [#18610](https://github.com/grafana/grafana/pull/18610), [@ryantxu](https://github.com/ryantxu) +- **Stackdriver**: Add extra alignment period options. [#18909](https://github.com/grafana/grafana/pull/18909), [@sunker](https://github.com/sunker) +- **Units**: Add South African Rand (ZAR) to currencies. [#18893](https://github.com/grafana/grafana/pull/18893), [@jeteon](https://github.com/jeteon) +- **Units**: Adding T,P,E,Z,and Y bytes. [#18706](https://github.com/grafana/grafana/pull/18706), [@chiqomar](https://github.com/chiqomar) + +### Bug Fixes + +- **Alerting**: Notification is sent when state changes from no_data to ok. [#18920](https://github.com/grafana/grafana/pull/18920), [@papagian](https://github.com/papagian) +- **Alerting**: fix duplicate alert states when the alert fails to save to the database. [#18216](https://github.com/grafana/grafana/pull/18216), [@kylebrandt](https://github.com/kylebrandt) +- **Alerting**: fix response popover prompt when add notification channels. [#18967](https://github.com/grafana/grafana/pull/18967), [@lzdw](https://github.com/lzdw) +- **CloudWatch**: Fix alerting for queries with Id (using GetMetricData). [#17899](https://github.com/grafana/grafana/pull/17899), [@alex-berger](https://github.com/alex-berger) +- **Explore**: Fix auto completion on label values for Loki. [#18988](https://github.com/grafana/grafana/pull/18988), [@aocenas](https://github.com/aocenas) +- **Explore**: Fixes crash using back button with a zoomed in graph. [#19122](https://github.com/grafana/grafana/pull/19122), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Fixes so queries in Explore are only run if Graph/Table is shown. [#19000](https://github.com/grafana/grafana/pull/19000), [@hugohaggmark](https://github.com/hugohaggmark) +- **MSSQL**: Change connectionstring to URL format to fix using passwords with semicolon. [#18384](https://github.com/grafana/grafana/pull/18384), [@Russiancold](https://github.com/Russiancold) +- **MSSQL**: Fix memory leak when debug enabled. [#19049](https://github.com/grafana/grafana/pull/19049), [@briangann](https://github.com/briangann) +- **Provisioning**: Allow escaping literal '$' with '$\$' in configs to avoid interpolation. [#18045](https://github.com/grafana/grafana/pull/18045), [@kylebrandt](https://github.com/kylebrandt) +- **TimePicker**: Fixes hiding time picker dropdown in FireFox. [#19154](https://github.com/grafana/grafana/pull/19154), [@hugohaggmark](https://github.com/hugohaggmark) + +## Breaking changes + +### Annotations + +There are some breaking changes in the annotations HTTP API for region annotations. Region annotations are now represented +using a single event instead of two separate events. Check breaking changes in HTTP API [below](#http-api) and [HTTP API documentation](https://grafana.com/docs/http_api/annotations/) for more details. + +### Docker + +Grafana is now using Alpine 3.10 as docker base image. + +### HTTP API + +- `GET /api/alert-notifications` now requires at least editor access. New `/api/alert-notifications/lookup` returns less information than `/api/alert-notifications` and can be access by any authenticated user. +- `GET /api/alert-notifiers` now requires at least editor access +- `GET /api/org/users` now requires org admin role. New `/api/org/users/lookup` returns less information than `/api/org/users` and can be access by users that are org admins, admin in any folder or admin of any team. +- `GET /api/annotations` no longer returns `regionId` property. +- `POST /api/annotations` no longer supports `isRegion` property. +- `PUT /api/annotations/:id` no longer supports `isRegion` property. +- `PATCH /api/annotations/:id` no longer supports `isRegion` property. +- `DELETE /api/annotations/region/:id` has been removed. + +## Deprecation notes + +### PhantomJS + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, is deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. + +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). + +# 6.3.7 (2019-11-22) + +### Bug Fixes + +- **CloudWatch**: Fix high CPU load [#20579](https://github.com/grafana/grafana/pull/20579) + +# 6.3.6 (2019-09-23) + +### Features / Enhancements + +- **Metrics**: Adds setting for turning off total stats metrics. [#19142](https://github.com/grafana/grafana/pull/19142), [@marefr](https://github.com/marefr) + +### Bug Fixes + +- **Database**: Rewrite system statistics query to perform better. [#19178](https://github.com/grafana/grafana/pull/19178), [@papagian](https://github.com/papagian) +- **Explore**: Fixes error when switching from prometheus to loki data sources. [#18599](https://github.com/grafana/grafana/pull/18599), [@kaydelaney](https://github.com/kaydelaney) + +# 6.3.5 (2019-09-02) + +### Upgrades + +- **Build**: Upgrade to go 1.12.9. [#18638](https://github.com/grafana/grafana/pull/18638), [@marcusolsson](https://github.com/marcusolsson) + +### Bug Fixes + +- **Dashboard**: Fixes dashboards init failed loading error for dashboards with panel links that had missing properties. [#18786](https://github.com/grafana/grafana/pull/18786), [@torkelo](https://github.com/torkelo) +- **Editor**: Fixes issue where only entire lines were being copied. [#18806](https://github.com/grafana/grafana/pull/18806), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Fixes query field layout in splitted view for Safari browsers. [#18654](https://github.com/grafana/grafana/pull/18654), [@hugohaggmark](https://github.com/hugohaggmark) +- **LDAP**: multildap + ldap integration. [#18588](https://github.com/grafana/grafana/pull/18588), [@markelog](https://github.com/markelog) +- **Profile/UserAdmin**: Fix for user agent parser crashes grafana-server on 32-bit builds. [#18788](https://github.com/grafana/grafana/pull/18788), [@marcusolsson](https://github.com/marcusolsson) +- **Prometheus**: Prevents panel editor crash when switching to Prometheus data source. [#18616](https://github.com/grafana/grafana/pull/18616), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: Changes brace-insertion behavior to be less annoying. [#18698](https://github.com/grafana/grafana/pull/18698), [@kaydelaney](https://github.com/kaydelaney) + +# 6.3.4 (2019-08-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/08/29/grafana-5.4.5-and-6.3.4-released-with-important-security-fix/) + +# 6.3.3 (2019-08-15) + +### Bug Fixes + +- **Annotations**: Fix failing annotation query when time series query is cancelled. [#18532](https://github.com/grafana/grafana/pull/18532), [@dprokop](https://github.com/dprokop) +- **Auth**: Do not set SameSite cookie attribute if cookie_samesite is none. [#18462](https://github.com/grafana/grafana/pull/18462), [@papagian](https://github.com/papagian) +- **DataLinks**: Apply scoped variables to data links correctly. [#18454](https://github.com/grafana/grafana/pull/18454), [@dprokop](https://github.com/dprokop) +- **DataLinks**: Respect timezone when displaying datapoint's timestamp in graph context menu. [#18461](https://github.com/grafana/grafana/pull/18461), [@dprokop](https://github.com/dprokop) +- **DataLinks**: Use datapoint timestamp correctly when interpolating variables. [#18459](https://github.com/grafana/grafana/pull/18459), [@dprokop](https://github.com/dprokop) +- **Explore**: Fix loading error for empty queries. [#18488](https://github.com/grafana/grafana/pull/18488), [@davkal](https://github.com/davkal) +- **Graph**: Fixes legend issue clicking on series line icon and issue with horizontal scrollbar being visible on windows. [#18563](https://github.com/grafana/grafana/pull/18563), [@torkelo](https://github.com/torkelo) +- **Graphite**: Avoid glob of single-value array variables . [#18420](https://github.com/grafana/grafana/pull/18420), [@gotjosh](https://github.com/gotjosh) +- **Prometheus**: Fix queries with label_replace remove the \$1 match when loading query editor. [#18480](https://github.com/grafana/grafana/pull/18480), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: More consistently allows for multi-line queries in editor. [#18362](https://github.com/grafana/grafana/pull/18362), [@kaydelaney](https://github.com/kaydelaney) +- **TimeSeries**: Assume values are all numbers. [#18540](https://github.com/grafana/grafana/pull/18540), [@ryantxu](https://github.com/ryantxu) + +# 6.3.2 (2019-08-07) + +### Bug Fixes + +- **Gauge/BarGauge**: Fixes issue with lost thresholds and an issue loading Gauge with avg stat. [#18375](https://github.com/grafana/grafana/pull/18375) + +# 6.3.1 (2019-08-07) + +### Bug Fixes + +- **PanelLinks**: Fixes crash issue with Gauge & Bar Gauge panels with panel links (drill down links). [#18430](https://github.com/grafana/grafana/pull/18430) + +# 6.3.0 (2019-08-06) + +### Features / Enhancements + +- **OAuth**: Do not set SameSite OAuth cookie if cookie_samesite is None. [#18392](https://github.com/grafana/grafana/pull/18392), [@papagian](https://github.com/papagian) + +### Bug Fixes + +- **PanelLinks**: Fix render issue when there is no panel description. [#18408](https://github.com/grafana/grafana/pull/18408), [@dehrax](https://github.com/dehrax) + +# 6.3.0-beta4 (2019-08-02) + +### Features / Enhancements + +- **Auth Proxy**: Include additional headers as part of the cache key. [#18298](https://github.com/grafana/grafana/pull/18298), [@gotjosh](https://github.com/gotjosh) + +# 6.3.0-beta3 (2019-08-02) + +### Bug Fixes + +- **OAuth**: Fix "missing saved state" OAuth login failure due to SameSite cookie policy. [#18332](https://github.com/grafana/grafana/pull/18332), [@papagian](https://github.com/papagian) +- **cli**: fix for recognizing when in dev mode.. [#18334](https://github.com/grafana/grafana/pull/18334), [@xlson](https://github.com/xlson) + +# 6.3.0-beta2 (2019-07-26) + +### Features / Enhancements + +- **Build grafana images consistently**. [#18224](https://github.com/grafana/grafana/pull/18224), [@hassanfarid](https://github.com/hassanfarid) +- **Docs**: SAML. [#18069](https://github.com/grafana/grafana/pull/18069), [@gotjosh](https://github.com/gotjosh) +- **Permissions**: Show plugins in nav for non admin users but hide plugin configuration. [#18234](https://github.com/grafana/grafana/pull/18234), [@aocenas](https://github.com/aocenas) +- **TimePicker**: Increase max height of quick range dropdown. [#18247](https://github.com/grafana/grafana/pull/18247), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **DataLinks**: Fixes incorrect interpolation of \${\_\_series_name} . [#18251](https://github.com/grafana/grafana/pull/18251), [@torkelo](https://github.com/torkelo) +- **Loki**: Display live tailed logs in correct order in Explore. [#18031](https://github.com/grafana/grafana/pull/18031), [@kaydelaney](https://github.com/kaydelaney) +- **PhantomJS**: Fixes rendering on Debian Buster. [#18162](https://github.com/grafana/grafana/pull/18162), [@xlson](https://github.com/xlson) +- **TimePicker**: Fixed style issue for custom range popover. [#18244](https://github.com/grafana/grafana/pull/18244), [@torkelo](https://github.com/torkelo) +- **Timerange**: Fixes a bug where custom time ranges didn't respect UTC. [#18248](https://github.com/grafana/grafana/pull/18248), [@kaydelaney](https://github.com/kaydelaney) +- **remote_cache**: Fix redis connstr parsing. [#18204](https://github.com/grafana/grafana/pull/18204), [@mblaschke](https://github.com/mblaschke) + +# 6.3.0-beta1 (2019-07-10) + +### Features / Enhancements + +- **Alerting**: Add tags to alert rules. [#10989](https://github.com/grafana/grafana/pull/10989), [@Thib17](https://github.com/Thib17) +- **Alerting**: Attempt to send email notifications to all given email addresses. [#16881](https://github.com/grafana/grafana/pull/16881), [@zhulongcheng](https://github.com/zhulongcheng) +- **Alerting**: Improve alert rule testing. [#16286](https://github.com/grafana/grafana/pull/16286), [@marefr](https://github.com/marefr) +- **Alerting**: Support for configuring content field for Discord alert notifier. [#17017](https://github.com/grafana/grafana/pull/17017), [@jan25](https://github.com/jan25) +- **Alertmanager**: Replace illegal chars with underscore in label names. [#17002](https://github.com/grafana/grafana/pull/17002), [@bergquist](https://github.com/bergquist) +- **Auth**: Allow expiration of API keys. [#17678](https://github.com/grafana/grafana/pull/17678), [@papagian](https://github.com/papagian) +- **Auth**: Return device, os and browser when listing user auth tokens in HTTP API. [#17504](https://github.com/grafana/grafana/pull/17504), [@shavonn](https://github.com/shavonn) +- **Auth**: Support list and revoke of user auth tokens in UI. [#17434](https://github.com/grafana/grafana/pull/17434), [@shavonn](https://github.com/shavonn) +- **AzureMonitor**: change clashing built-in Grafana variables/macro names for Azure Logs. [#17140](https://github.com/grafana/grafana/pull/17140), [@shavonn](https://github.com/shavonn) +- **CloudWatch**: Made region visible for AWS Cloudwatch Expressions. [#17243](https://github.com/grafana/grafana/pull/17243), [@utkarshcmu](https://github.com/utkarshcmu) +- **Cloudwatch**: Add AWS DocDB metrics. [#17241](https://github.com/grafana/grafana/pull/17241), [@utkarshcmu](https://github.com/utkarshcmu) +- **Dashboard**: Use timezone dashboard setting when exporting to CSV. [#18002](https://github.com/grafana/grafana/pull/18002), [@dehrax](https://github.com/dehrax) +- **Data links**. [#17267](https://github.com/grafana/grafana/pull/17267), [@torkelo](https://github.com/torkelo) +- **Docker**: Switch base image to ubuntu:latest from debian:stretch to avoid security issues.. [#17066](https://github.com/grafana/grafana/pull/17066), [@bergquist](https://github.com/bergquist) +- **Elasticsearch**: Support for visualizing logs in Explore . [#17605](https://github.com/grafana/grafana/pull/17605), [@marefr](https://github.com/marefr) +- **Explore**: Adds Live option for supported data sources. [#17062](https://github.com/grafana/grafana/pull/17062), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Adds orgId to URL for sharing purposes. [#17895](https://github.com/grafana/grafana/pull/17895), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Adds support for new loki 'start' and 'end' params for labels endpoint. [#17512](https://github.com/grafana/grafana/pull/17512), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Adds support for toggling raw query mode in explore. [#17870](https://github.com/grafana/grafana/pull/17870), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Allow switching between metrics and logs . [#16959](https://github.com/grafana/grafana/pull/16959), [@marefr](https://github.com/marefr) +- **Explore**: Combines the timestamp and local time columns into one. [#17775](https://github.com/grafana/grafana/pull/17775), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Display log lines context . [#17097](https://github.com/grafana/grafana/pull/17097), [@dprokop](https://github.com/dprokop) +- **Explore**: Don't parse log levels if provided by field or label. [#17180](https://github.com/grafana/grafana/pull/17180), [@marefr](https://github.com/marefr) +- **Explore**: Improves performance of Logs element by limiting re-rendering. [#17685](https://github.com/grafana/grafana/pull/17685), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Support for new LogQL filtering syntax. [#16674](https://github.com/grafana/grafana/pull/16674), [@davkal](https://github.com/davkal) +- **Explore**: Use new TimePicker from Grafana/UI. [#17793](https://github.com/grafana/grafana/pull/17793), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: handle newlines in LogRow Highlighter. [#17425](https://github.com/grafana/grafana/pull/17425), [@rrfeng](https://github.com/rrfeng) +- **Graph**: Added new fill gradient option. [#17528](https://github.com/grafana/grafana/pull/17528), [@torkelo](https://github.com/torkelo) +- **GraphPanel**: Don't sort series when legend table & sort column is not visible . [#17095](https://github.com/grafana/grafana/pull/17095), [@shavonn](https://github.com/shavonn) +- **InfluxDB**: Support for visualizing logs in Explore. [#17450](https://github.com/grafana/grafana/pull/17450), [@hugohaggmark](https://github.com/hugohaggmark) +- **Logging**: Login and Logout actions (#17760). [#17883](https://github.com/grafana/grafana/pull/17883), [@ATTron](https://github.com/ATTron) +- **Logging**: Move log package to pkg/infra. [#17023](https://github.com/grafana/grafana/pull/17023), [@zhulongcheng](https://github.com/zhulongcheng) +- **Metrics**: Expose stats about roles as metrics. [#17469](https://github.com/grafana/grafana/pull/17469), [@bergquist](https://github.com/bergquist) +- **MySQL/Postgres/MSSQL**: Add parsing for day, weeks and year intervals in macros. [#13086](https://github.com/grafana/grafana/pull/13086), [@bernardd](https://github.com/bernardd) +- **MySQL**: Add support for periodically reloading client certs. [#14892](https://github.com/grafana/grafana/pull/14892), [@tpetr](https://github.com/tpetr) +- **Plugins**: replace dataFormats list with skipDataQuery flag in plugin.json. [#16984](https://github.com/grafana/grafana/pull/16984), [@ryantxu](https://github.com/ryantxu) +- **Prometheus**: Take timezone into account for step alignment. [#17477](https://github.com/grafana/grafana/pull/17477), [@fxmiii](https://github.com/fxmiii) +- **Prometheus**: Use overridden panel range for \$\_\_range instead of dashboard range. [#17352](https://github.com/grafana/grafana/pull/17352), [@patrick246](https://github.com/patrick246) +- **Prometheus**: added time range filter to series labels query. [#16851](https://github.com/grafana/grafana/pull/16851), [@FUSAKLA](https://github.com/FUSAKLA) +- **Provisioning**: Support folder that doesn't exist yet in dashboard provisioning. [#17407](https://github.com/grafana/grafana/pull/17407), [@Nexucis](https://github.com/Nexucis) +- **Refresh picker**: Handle empty intervals. [#17585](https://github.com/grafana/grafana/pull/17585), [@dehrax](https://github.com/dehrax) +- **Singlestat**: Add y min/max config to singlestat sparklines. [#17527](https://github.com/grafana/grafana/pull/17527), [@pitr](https://github.com/pitr) +- **Snapshot**: use given key and deleteKey. [#16876](https://github.com/grafana/grafana/pull/16876), [@zhulongcheng](https://github.com/zhulongcheng) +- **Templating**: Correctly display \_\_text in multi-value variable after page reload. [#17840](https://github.com/grafana/grafana/pull/17840), [@EduardSergeev](https://github.com/EduardSergeev) +- **Templating**: Support selecting all filtered values of a multi-value variable. [#16873](https://github.com/grafana/grafana/pull/16873), [@r66ad](https://github.com/r66ad) +- **Tracing**: allow propagation with Zipkin headers. [#17009](https://github.com/grafana/grafana/pull/17009), [@jrockway](https://github.com/jrockway) +- **Users**: Disable users removed from LDAP. [#16820](https://github.com/grafana/grafana/pull/16820), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **SAML**: Add SAML as an authentication option (Enterprise) + +### Bug Fixes + +- **AddPanel**: Fix issue when removing moved add panel widget . [#17659](https://github.com/grafana/grafana/pull/17659), [@dehrax](https://github.com/dehrax) +- **CLI**: Fix encrypt-datasource-passwords fails with sql error. [#18014](https://github.com/grafana/grafana/pull/18014), [@marefr](https://github.com/marefr) +- **Elasticsearch**: Fix default max concurrent shard requests. [#17770](https://github.com/grafana/grafana/pull/17770), [@marefr](https://github.com/marefr) +- **Explore**: Fix browsing back to dashboard panel. [#17061](https://github.com/grafana/grafana/pull/17061), [@jschill](https://github.com/jschill) +- **Explore**: Fix filter by series level in logs graph. [#17798](https://github.com/grafana/grafana/pull/17798), [@marefr](https://github.com/marefr) +- **Explore**: Fix issues when loading and both graph/table are collapsed. [#17113](https://github.com/grafana/grafana/pull/17113), [@marefr](https://github.com/marefr) +- **Explore**: Fix selection/copy of log lines. [#17121](https://github.com/grafana/grafana/pull/17121), [@marefr](https://github.com/marefr) +- **Fix**: Wrap value of multi variable in array when coming from URL. [#16992](https://github.com/grafana/grafana/pull/16992), [@aocenas](https://github.com/aocenas) +- **Frontend**: Fix for Json tree component not working. [#17608](https://github.com/grafana/grafana/pull/17608), [@srid12](https://github.com/srid12) +- **Graphite**: Fix for issue with alias function being moved last. [#17791](https://github.com/grafana/grafana/pull/17791), [@torkelo](https://github.com/torkelo) +- **Graphite**: Fixes issue with seriesByTag & function with variable param. [#17795](https://github.com/grafana/grafana/pull/17795), [@torkelo](https://github.com/torkelo) +- **Graphite**: use POST for /metrics/find requests. [#17814](https://github.com/grafana/grafana/pull/17814), [@papagian](https://github.com/papagian) +- **HTTP Server**: Serve Grafana with a custom URL path prefix. [#17048](https://github.com/grafana/grafana/pull/17048), [@jan25](https://github.com/jan25) +- **InfluxDB**: Fixes single quotes are not escaped in label value filters. [#17398](https://github.com/grafana/grafana/pull/17398), [@Panzki](https://github.com/Panzki) +- **Prometheus**: Correctly escape '|' literals in interpolated PromQL variables. [#16932](https://github.com/grafana/grafana/pull/16932), [@Limess](https://github.com/Limess) +- **Prometheus**: Fix when adding label for metrics which contains colons in Explore. [#16760](https://github.com/grafana/grafana/pull/16760), [@tolwi](https://github.com/tolwi) +- **SinglestatPanel**: Remove background color when value turns null. [#17552](https://github.com/grafana/grafana/pull/17552), [@druggieri](https://github.com/druggieri) + +# 6.2.5 (2019-06-25) + +### Features / Enhancements + +- **Grafana-CLI**: Wrapper for `grafana-cli` within RPM/DEB packages and config/homepath are now global flags. [#17695](https://github.com/grafana/grafana/pull/17695), [@gotjosh](https://github.com/gotjosh) +- **Panel**: Fully escape html in drilldown links (was only sanitized before) . [#17731](https://github.com/grafana/grafana/pull/17731), [@dehrax](https://github.com/dehrax) + +### Bug Fixes + +- **Config**: Fix connectionstring for remote_cache in defaults.ini. [#17675](https://github.com/grafana/grafana/pull/17675), [@kylebrandt](https://github.com/kylebrandt) +- **Elasticsearch**: Fix empty query (via template variable) should be sent as wildcard. [#17488](https://github.com/grafana/grafana/pull/17488), [@davewat](https://github.com/davewat) +- **HTTP-Server**: Fix Strict-Transport-Security header. [#17644](https://github.com/grafana/grafana/pull/17644), [@kylebrandt](https://github.com/kylebrandt) +- **TablePanel**: fix annotations display. [#17646](https://github.com/grafana/grafana/pull/17646), [@ryantxu](https://github.com/ryantxu) + +# 6.2.4 (2019-06-18) + +### Bug Fixes + +- **Grafana-CLI**: Fix receiving flags via command line . [#17617](https://github.com/grafana/grafana/pull/17617), [@gotjosh](https://github.com/gotjosh) +- **HTTPServer**: Fix X-XSS-Protection header formatting. [#17620](https://github.com/grafana/grafana/pull/17620), [@yverry](https://github.com/yverry) + +# 6.2.3 (2019-06-17) + +### Known issues + +- **grafana-cli**: The argument `--pluginsDir` is not working. +- **docker**: Due to above problem with grafana-cli the docker run will fail to start the container if you're installing plugins using the `GF_INSTALL_PLUGINS` environment variable. We have removed 6.2.3 tag from docker hub and latest tag now points to 6.2.2. + +More details in bug report: https://github.com/grafana/grafana/issues/17613 + +### Features / Enhancements + +- **AuthProxy**: Optimistic lock pattern for remote cache Set. [#17485](https://github.com/grafana/grafana/pull/17485), [@papagian](https://github.com/papagian) +- **HTTPServer**: Options for returning new headers X-Content-Type-Options, X-XSS-Protection and Strict-Transport-Security. [#17522](https://github.com/grafana/grafana/pull/17522), [@kylebrandt](https://github.com/kylebrandt) + +### Bug Fixes + +- **Auth Proxy**: Fix non-negative cache TTL. [#17495](https://github.com/grafana/grafana/pull/17495), [@kylebrandt](https://github.com/kylebrandt) +- **Grafana-CLI**: Fix receiving configuration flags from the command line. [#17606](https://github.com/grafana/grafana/pull/17606), [@gotjosh](https://github.com/gotjosh) +- **OAuth**: Fix for wrong user token updated on OAuth refresh in DS proxy. [#17541](https://github.com/grafana/grafana/pull/17541), [@redbaron](https://github.com/redbaron) +- **remote_cache**: Fix redis. [#17483](https://github.com/grafana/grafana/pull/17483), [@kylebrandt](https://github.com/kylebrandt) + +# 6.2.2 (2019-06-05) + +### Features / Enhancements + +- **Security**: Prevent CSV formula injection attack when exporting data. [#17363](https://github.com/grafana/grafana/pull/17363), [@DanCech](https://github.com/DanCech) + +### Bug Fixes + +- **CloudWatch**: Fixes error when hiding/disabling queries . [#17283](https://github.com/grafana/grafana/pull/17283), [@jpiccari](https://github.com/jpiccari) +- **Database**: Fixed slow permission query in folder/dashboard search. [#17427](https://github.com/grafana/grafana/pull/17427), [@aocenas](https://github.com/aocenas) +- **Explore**: Fixed updating time range before running queries. [#17349](https://github.com/grafana/grafana/pull/17349), [@marefr](https://github.com/marefr) +- **Plugins**: Fixed plugin config page navigation when using subpath. [#17364](https://github.com/grafana/grafana/pull/17364), [@torkelo](https://github.com/torkelo) + +# 6.2.1 (2019-05-27) + +### Features / Enhancements + +- **CLI**: Add command to migrate all data sources to use encrypted password fields . [#17118](https://github.com/grafana/grafana/pull/17118), [@aocenas](https://github.com/aocenas) +- **Gauge/BarGauge**: Improvements to auto value font size . [#17292](https://github.com/grafana/grafana/pull/17292), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **Auth Proxy**: Resolve database is locked errors. [#17274](https://github.com/grafana/grafana/pull/17274), [@marefr](https://github.com/marefr) +- **Database**: Retry transaction if sqlite returns database is locked error. [#17276](https://github.com/grafana/grafana/pull/17276), [@marefr](https://github.com/marefr) +- **Explore**: Fixes so clicking in a Prometheus Table the query is filtered by clicked value. [#17083](https://github.com/grafana/grafana/pull/17083), [@hugohaggmark](https://github.com/hugohaggmark) +- **Singlestat**: Fixes issue with value placement and line wraps. [#17249](https://github.com/grafana/grafana/pull/17249), [@torkelo](https://github.com/torkelo) +- **Tech**: Update jQuery to 3.4.1 to fix issue on iOS 10 based browsers as well as Chrome 53.x . [#17290](https://github.com/grafana/grafana/pull/17290), [@timbutler](https://github.com/timbutler) + +# 6.2.0 (2019-05-22) + +### Bug Fixes + +- **BarGauge**: Fix for negative min values. [#17192](https://github.com/grafana/grafana/pull/17192), [@torkelo](https://github.com/torkelo) +- **Gauge/BarGauge**: Fix for issues editing min & max options. [#17174](https://github.com/grafana/grafana/pull/17174) +- **Search**: Make only folder name only open search with current folder filter. [#17226](https://github.com/grafana/grafana/pull/17226) +- **AzureMonitor**: Revert to clearing chained dropdowns. [#17212](https://github.com/grafana/grafana/pull/17212) + +### Breaking Changes + +- **Plugins**: Data source plugins that process hidden queries need to add a "hiddenQueries: true" attribute in plugin.json. [#17124](https://github.com/grafana/grafana/pull/17124), [@ryantxu](https://github.com/ryantxu) + +### Removal of old deprecated package repository + +5 months ago we deprecated our old package cloud repository and [replaced it](https://grafana.com/blog/2019/01/05/moving-to-packages.grafana.com/) with our own. We will remove the old depreciated +repo on July 1st. Make sure you have switched to the new repo by then. The new repository has all our old releases so you are not required to upgrade just to switch package repository. + +# 6.2.0-beta2 (2019-05-15) + +### Features / Enhancements + +- **Plugins**: Support templated urls in plugin routes. [#16599](https://github.com/grafana/grafana/pull/16599), [@briangann](https://github.com/briangann) +- **Packaging**: New MSI windows installer package\*\*. [#17073](https://github.com/grafana/grafana/pull/17073), [@briangann](https://github.com/briangann) + +### Bug Fixes + +- **Dashboard**: Fixes blank dashboard after window resize with panel without title. [#16942](https://github.com/grafana/grafana/pull/16942), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fixes lazy loading & expanding collapsed rows on mobile. [#17055](https://github.com/grafana/grafana/pull/17055), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fixes scrolling issues for Edge browser. [#17033](https://github.com/grafana/grafana/pull/17033), [@jschill](https://github.com/jschill) +- **Dashboard**: Show refresh button in first kiosk(tv) mode. [#17032](https://github.com/grafana/grafana/pull/17032), [@torkelo](https://github.com/torkelo) +- **Explore**: Fix empty result from data source should render logs container. [#16999](https://github.com/grafana/grafana/pull/16999), [@marefr](https://github.com/marefr) +- **Explore**: Fixes so clicking in a Prometheus Table the query is filtered by clicked value. [#17083](https://github.com/grafana/grafana/pull/17083), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Makes it possible to zoom in Explore/Loki/Graph without exception. [#16991](https://github.com/grafana/grafana/pull/16991), [@hugohaggmark](https://github.com/hugohaggmark) +- **Gauge**: Fixes orientation issue after switching from BarGauge to Gauge. [#17064](https://github.com/grafana/grafana/pull/17064), [@torkelo](https://github.com/torkelo) +- **GettingStarted**: Fixes layout issues in getting started panel. [#16941](https://github.com/grafana/grafana/pull/16941), [@torkelo](https://github.com/torkelo) +- **InfluxDB**: Fix HTTP method should default to GET. [#16949](https://github.com/grafana/grafana/pull/16949), [@StephenSorriaux](https://github.com/StephenSorriaux) +- **Panels**: Fixed alert icon position in panel header. [#17070](https://github.com/grafana/grafana/pull/17070), [@torkelo](https://github.com/torkelo) +- **Panels**: Fixes panel error tooltip not showing. [#16993](https://github.com/grafana/grafana/pull/16993), [@torkelo](https://github.com/torkelo) +- **Plugins**: Fix how datemath utils are exposed to plugins. [#16976](https://github.com/grafana/grafana/pull/16976), [@marefr](https://github.com/marefr) +- **Singlestat**: fixed centering issue for very small panels. [#16944](https://github.com/grafana/grafana/pull/16944), [@torkelo](https://github.com/torkelo) +- **Search**: Scroll issue in dashboard search in latest Chrome. [#17054](https://github.com/grafana/grafana/pull/17054), [@jschill](https://github.com/jschill) +- **Docker**: Prevent a permission denied error when writing files to the default provisioning directory. [#16831](https://github.com/grafana/grafana/pull/16831), [@wmedlar](https://github.com/wmedlar) +- **Gauge**: Adds background shade to gauge track and improves height usage. [#17019](https://github.com/grafana/grafana/pull/17019), [@torkelo](https://github.com/torkelo) +- **RemoteCache**: Avoid race condition in Set causing error on insert. . [#17082](https://github.com/grafana/grafana/pull/17082), [@bergquist](https://github.com/bergquist) + +# 6.2.0-beta1 (2019-05-07) + +### Features / Enhancements + +- **Admin**: Add more stats about roles. [#16667](https://github.com/grafana/grafana/pull/16667), [@bergquist](https://github.com/bergquist) +- **Alert list panel**: Support variables in filters. [#16892](https://github.com/grafana/grafana/pull/16892), [@psschand](https://github.com/psschand) +- **Alerting**: Adjust label for send on all alerts to default . [#16554](https://github.com/grafana/grafana/pull/16554), [@simPod](https://github.com/simPod) +- **Alerting**: Makes timeouts and retries configurable. [#16259](https://github.com/grafana/grafana/pull/16259), [@kobehaha](https://github.com/kobehaha) +- **Alerting**: No notification when going from no data to pending. [#16905](https://github.com/grafana/grafana/pull/16905), [@bergquist](https://github.com/bergquist) +- **Alerting**: Pushover alert, support for different sound for OK. [#16525](https://github.com/grafana/grafana/pull/16525), [@Hofls](https://github.com/Hofls) +- **Auth**: Enable retries and transaction for some db calls for auth tokens . [#16785](https://github.com/grafana/grafana/pull/16785), [@bergquist](https://github.com/bergquist) +- **AzureMonitor**: Adds support for multiple subscriptions per data source. [#16922](https://github.com/grafana/grafana/pull/16922), [@daniellee](https://github.com/daniellee) +- **Bar Gauge**: New multi series enabled gauge like panel with horizontal and vertical layouts and 3 display modes. [#16918](https://github.com/grafana/grafana/pull/16918), [@torkelo](https://github.com/torkelo) +- **Build**: Upgrades to golang 1.12.4. [#16545](https://github.com/grafana/grafana/pull/16545), [@bergquist](https://github.com/bergquist) +- **CloudWatch**: Update AWS/IoT metric and dimensions. [#16337](https://github.com/grafana/grafana/pull/16337), [@nonamef](https://github.com/nonamef) +- **Config**: Show user-friendly error message instead of stack trace. [#16564](https://github.com/grafana/grafana/pull/16564), [@Hofls](https://github.com/Hofls) +- **Dashboard**: Enable filtering dashboards in search by current folder. [#16790](https://github.com/grafana/grafana/pull/16790), [@dprokop](https://github.com/dprokop) +- **Dashboard**: Lazy load out of view panels . [#15554](https://github.com/grafana/grafana/pull/15554), [@ryantxu](https://github.com/ryantxu) +- **DataProxy**: Restore Set-Cookie header after proxy request. [#16838](https://github.com/grafana/grafana/pull/16838), [@marefr](https://github.com/marefr) +- **Data Sources**: Add pattern validation for time input on data source config pages. [#16837](https://github.com/grafana/grafana/pull/16837), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Add 7.x version support. [#16646](https://github.com/grafana/grafana/pull/16646), [@alcidesv](https://github.com/alcidesv) +- **Explore**: Adds reconnect for failing data source. [#16226](https://github.com/grafana/grafana/pull/16226), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Support user timezone. [#16469](https://github.com/grafana/grafana/pull/16469), [@marefr](https://github.com/marefr) +- **InfluxDB**: Add support for POST HTTP verb. [#16690](https://github.com/grafana/grafana/pull/16690), [@StephenSorriaux](https://github.com/StephenSorriaux) +- **Loki**: Search is now case insensitive. [#15948](https://github.com/grafana/grafana/pull/15948), [@steven-sheehy](https://github.com/steven-sheehy) +- **OAuth**: Update jwt regexp to include `=`. [#16521](https://github.com/grafana/grafana/pull/16521), [@DanCech](https://github.com/DanCech) +- **Panels**: No title will no longer make panel header take up space. [#16884](https://github.com/grafana/grafana/pull/16884), [@torkelo](https://github.com/torkelo) +- **Prometheus**: Adds tracing headers for Prometheus datasource. [#16724](https://github.com/grafana/grafana/pull/16724), [@svagner](https://github.com/svagner) +- **Provisioning**: Add API endpoint to reload provisioning configs. [#16579](https://github.com/grafana/grafana/pull/16579), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Do not allow deletion of provisioned dashboards. [#16211](https://github.com/grafana/grafana/pull/16211), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Interpolate env vars in provisioning files. [#16499](https://github.com/grafana/grafana/pull/16499), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Support FolderUid in Dashboard Provisioning Config. [#16559](https://github.com/grafana/grafana/pull/16559), [@swtch1](https://github.com/swtch1) +- **Security**: Add new setting allow_embedding. [#16853](https://github.com/grafana/grafana/pull/16853), [@marefr](https://github.com/marefr) +- **Security**: Store data source passwords encrypted in secureJsonData. [#16175](https://github.com/grafana/grafana/pull/16175), [@aocenas](https://github.com/aocenas) +- **UX**: Improve Grafana usage for smaller screens. [#16783](https://github.com/grafana/grafana/pull/16783), [@torkelo](https://github.com/torkelo) +- **Units**: Add angle units, Arc Minutes and Seconds. [#16271](https://github.com/grafana/grafana/pull/16271), [@Dripoul](https://github.com/Dripoul) + +### Bug Fixes + +- **Build**: Fix bug where grafana didn't start after mysql on rpm packages. [#16917](https://github.com/grafana/grafana/pull/16917), [@bergquist](https://github.com/bergquist) +- **CloudWatch**: Fixes query order not affecting series ordering & color. [#16408](https://github.com/grafana/grafana/pull/16408), [@mtanda](https://github.com/mtanda) +- **CloudWatch**: Use default alias if there is no alias for metrics. [#16732](https://github.com/grafana/grafana/pull/16732), [@utkarshcmu](https://github.com/utkarshcmu) +- **Config**: Fixes bug where timeouts for alerting was not parsed correctly. [#16784](https://github.com/grafana/grafana/pull/16784), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Fix view percentiles metric in table without date histogram. [#15686](https://github.com/grafana/grafana/pull/15686), [@Igor-Ratsuk](https://github.com/Igor-Ratsuk) +- **Explore**: Prevents histogram loading from killing Prometheus instance. [#16768](https://github.com/grafana/grafana/pull/16768), [@hugohaggmark](https://github.com/hugohaggmark) +- **Graph**: Allow override decimals to fully override. [#16414](https://github.com/grafana/grafana/pull/16414), [@torkelo](https://github.com/torkelo) +- **Mixed Data Source**: Fix error when one query is disabled. [#16409](https://github.com/grafana/grafana/pull/16409), [@marefr](https://github.com/marefr) +- **Search**: Fixes search limits and adds a page parameter. [#16458](https://github.com/grafana/grafana/pull/16458), [@torkelo](https://github.com/torkelo) +- **Security**: Responses from backend should not be cached. [#16848](https://github.com/grafana/grafana/pull/16848), [@marefr](https://github.com/marefr) + +### Breaking changes + +- **Gauge Panel**: The suffix / prefix options have been removed from the new Gauge Panel (introduced in v6.0). [#16870](https://github.com/grafana/grafana/issues/16870). + +# 6.1.6 (2019-04-29) + +### Features / Enhancements + +- **Security**: Bump jQuery to 3.4.0 . [#16761](https://github.com/grafana/grafana/pull/16761), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **Playlist**: Fix loading dashboards by tag. [#16727](https://github.com/grafana/grafana/pull/16727), [@marefr](https://github.com/marefr) + +# 6.1.5 (2019-04-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/04/29/grafana-5.4.4-and-6.1.6-released-with-important-security-fix/) + +# 6.1.4 (2019-04-16) + +### Bug Fixes + +- **DataPanel**: Added missing built-in interval variables to scopedVars. [#16556](https://github.com/grafana/grafana/pull/16556), [@torkelo](https://github.com/torkelo) +- **Explore**: Adds maxDataPoints to data source query options . [#16513](https://github.com/grafana/grafana/pull/16513), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Fixes so intervals are recalculated on run query. [#16510](https://github.com/grafana/grafana/pull/16510), [@hugohaggmark](https://github.com/hugohaggmark) +- **Heatmap**: Fix for empty graph when panel is too narrow (#16378). [#16460](https://github.com/grafana/grafana/pull/16460), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: Fixed auto decimals when bucket name is not number. [#16609](https://github.com/grafana/grafana/pull/16609), [@torkelo](https://github.com/torkelo) +- **QueryInspector**: Now shows error responses again. [#16514](https://github.com/grafana/grafana/pull/16514), [@torkelo](https://github.com/torkelo) + +# 6.1.3 (2019-04-09) + +### Bug Fixes + +- **Graph**: Fixed auto decimals in legend values for some units like `ms` and `s`. [#16455](https://github.com/grafana/grafana/pull/16455), [@torkelo](https://github.com/torkelo) +- **Graph**: Fixed png rendering with legend to the right. [#16463](https://github.com/grafana/grafana/pull/16463), [@torkelo](https://github.com/torkelo) +- **Singlestat**: Use decimals when manually specified. [#16451](https://github.com/grafana/grafana/pull/16451), [@torkelo](https://github.com/torkelo) +- **UI Switch**: Fix broken UI switches. Fixes Default Data Source switch, Explore Logs switches, Gauge option switches. [#16303](https://github.com/grafana/grafana/pull/16303), [@dprokop](https://github.com/dprokop) + +# 6.1.2 (2019-04-08) + +### Bug Fixes + +- **Graph**: Fixed series legend color for hidden series. [#16438](https://github.com/grafana/grafana/pull/16438), [@Ijin08](https://github.com/Ijin08) +- **Graph**: Fixed tooltip highlight on white theme. [#16429](https://github.com/grafana/grafana/pull/16429), [@torkelo](https://github.com/torkelo) +- **Styles**: Fixed menu hover highlight border. [#16431](https://github.com/grafana/grafana/pull/16431), [@torkelo](https://github.com/torkelo) +- **Singlestat Panel**: Correctly use the override decimals. [#16413](https://github.com/grafana/grafana/pull/16413), [@torkelo](https://github.com/torkelo) + +# 6.1.1 (2019-04-05) + +### Bug Fixes + +- **Alerting**: Notification channel http api fixes. [#16379](https://github.com/grafana/grafana/pull/16379), [@marefr](https://github.com/marefr) +- **Graphite**: Editing graphite query function now works again. [#16390](https://github.com/grafana/grafana/pull/16390), [@torkelo](https://github.com/torkelo) +- **Playlist**: Kiosk & auto fit panels modes are working normally again . [#16403](https://github.com/grafana/grafana/pull/16403), [@torkelo](https://github.com/torkelo) +- **QueryEditors**: Toggle edit mode now always work on slower computers. [#16394](https://github.com/grafana/grafana/pull/16394), [@seanlaff](https://github.com/seanlaff) + +# 6.1.0 (2019-04-03) + +### Bug Fixes + +- **CloudWatch**: Fix for dimension value list when changing dimension key. [#16356](https://github.com/grafana/grafana/pull/16356), [@mtanda](https://github.com/mtanda) +- **Graphite**: Editing function arguments now works again. [#16297](https://github.com/grafana/grafana/pull/16297), [@torkelo](https://github.com/torkelo) +- **InfluxDB**: Fix tag names with periods in alert evaluation. [#16255](https://github.com/grafana/grafana/pull/16255), [@floyd-may](https://github.com/floyd-may) +- **PngRendering**: Fix for panel height & title centering . [#16351](https://github.com/grafana/grafana/pull/16351), [@torkelo](https://github.com/torkelo) +- **Templating**: Fix for editing query variables. [#16299](https://github.com/grafana/grafana/pull/16299), [@torkelo](https://github.com/torkelo) + +# 6.1.0-beta1 (2019-03-27) + +### New Features + +- **Prometheus**: adhoc filter support [#8253](https://github.com/grafana/grafana/issues/8253), thx [@mtanda](https://github.com/mtanda) +- **Permissions**: Editors can become admin for dashboards, folders and teams they create. [#15977](https://github.com/grafana/grafana/pull/15977), [@xlson](https://github.com/xlson) + +### Minor + +- **Auth**: Support listing and revoking auth tokens via API [#15836](https://github.com/grafana/grafana/issues/15836) +- **Alerting**: DingDing notification channel now includes alert values. [#13825](https://github.com/grafana/grafana/pull/13825), [@athurg](https://github.com/athurg) +- **Alerting**: Notification channel http api enhancements. [#16219](https://github.com/grafana/grafana/pull/16219), [@marefr](https://github.com/marefr) +- **CloudWatch**: Update metrics/dimensions list. [#16137](https://github.com/grafana/grafana/pull/16137), [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow) +- **Cache**: Adds support for using out of proc caching in the backend [#10816](https://github.com/grafana/grafana/issues/10816) +- **Dashboard**: New keyboard shortcut `d l` toggles all Graph legends in a dashboard. [#15770](https://github.com/grafana/grafana/pull/15770), [@jsferrei](https://github.com/jsferrei) +- **Data Source**: Only log connection string in dev environment [#16001](https://github.com/grafana/grafana/issues/16001) +- **DataProxy**: Add custom header (X-Grafana-User) to data source requests with the current username. [#15998](https://github.com/grafana/grafana/pull/15998), [@aocenas](https://github.com/aocenas) +- **DataProxy**: Make it possible to add user details to requests sent to the dataproxy [#6359](https://github.com/grafana/grafana/issues/6359) and [#15931](https://github.com/grafana/grafana/issues/15931) +- **DataProxy**: Adds oauth pass-through option for data sources. [#15205](https://github.com/grafana/grafana/pull/15205), [@seanlaff](https://github.com/seanlaff) +- **Explore**: Hide empty duplicates column in logs viewer. [#15982](https://github.com/grafana/grafana/pull/15982), [@steven-sheehy](https://github.com/steven-sheehy) +- **Explore**: Make it possible to close left pane of split view. [#16155](https://github.com/grafana/grafana/pull/16155), [@dprokop](https://github.com/dprokop) +- **Explore**: Move back / forward with browser buttons now works. [#16150](https://github.com/grafana/grafana/pull/16150), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Update Loki labels when label selector is opened. [#16131](https://github.com/grafana/grafana/pull/16131), [@dprokop](https://github.com/dprokop) +- **Graph Panel**: New options for X-axis Min & Max (for histograms). [#14877](https://github.com/grafana/grafana/pull/14877), [@papagian](https://github.com/papagian) +- **Heatmap**: You can now choose to hide buckets with zero value. [#15934](https://github.com/grafana/grafana/pull/15934), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683) +- **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683) +- **Prometheus**: Change alignment of range queries to end before now and not in future. [#16110](https://github.com/grafana/grafana/pull/16110), [@davkal](https://github.com/davkal) +- **Prometheus**: Dedup annotations events with same timestamp . [#16152](https://github.com/grafana/grafana/pull/16152), [@torkelo](https://github.com/torkelo) +- **SQL**: Use default min interval of 1m for all SQL data sources. [#15799](https://github.com/grafana/grafana/pull/15799), [@marefr](https://github.com/marefr) +- **TablePanel**: Column color style now works even after removing columns. [#16227](https://github.com/grafana/grafana/pull/16227), [@torkelo](https://github.com/torkelo) +- **Templating**: Custom variable value now escapes all backslashes properly. [#15980](https://github.com/grafana/grafana/pull/15980), [@srid12](https://github.com/srid12) +- **Templating**: Data source variable now supports multi-value for uses cases that involve repeating panels & rows. [#15914](https://github.com/grafana/grafana/pull/15914), [@torkelo](https://github.com/torkelo) +- **VictorOps**: Adds more information to the victor ops notifiers [#15744](https://github.com/grafana/grafana/issues/15744), thx [@zhulongcheng](https://github.com/zhulongcheng) + +### Bug Fixes + +- **Alerting**: Don't include non-existing image in MS Teams notifications. [#16116](https://github.com/grafana/grafana/pull/16116), [@SGI495](https://github.com/SGI495) +- **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506) +- **Annotations**: Fix for native annotations filtered by template variable with pipe. [#15515](https://github.com/grafana/grafana/pull/15515), [@marefr](https://github.com/marefr) +- **Dashboard**: Fix for time regions spanning across midnight. [#16201](https://github.com/grafana/grafana/pull/16201), [@marefr](https://github.com/marefr) +- **Data Source**: Handles nil jsondata field gracefully [#14239](https://github.com/grafana/grafana/issues/14239) +- **Data Source**: Empty user/password was not updated when updating data sources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619) +- **Elasticsearch**: Fixes using template variables in the alias field. [#16229](https://github.com/grafana/grafana/pull/16229), [@daniellee](https://github.com/daniellee) +- **Elasticsearch**: Fix incorrect index pattern padding in alerting queries. [#15892](https://github.com/grafana/grafana/pull/15892), [@sandlis](https://github.com/sandlis) +- **Explore**: Fix for Prometheus autocomplete not working in Firefox. [#16192](https://github.com/grafana/grafana/pull/16192), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Fix for url does not keep query after browser refresh. [#16189](https://github.com/grafana/grafana/pull/16189), [@hugohaggmark](https://github.com/hugohaggmark) +- **Gauge**: Interpolate scoped variables in repeated gauges [#15739](https://github.com/grafana/grafana/issues/15739) +- **Graphite**: Fixed issue with using series ref and series by tag. [#16111](https://github.com/grafana/grafana/pull/16111), [@torkelo](https://github.com/torkelo) +- **Graphite**: Fixed variable quoting when variable value is numeric. [#16149](https://github.com/grafana/grafana/pull/16149), [@torkelo](https://github.com/torkelo) +- **Heatmap**: Fixes Y-axis tick labels being in wrong order for some Prometheus queries. [#15932](https://github.com/grafana/grafana/pull/15932), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: Negative values are now displayed correctly in graph & legend. [#15953](https://github.com/grafana/grafana/pull/15953), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019) +- **InfluxDB**: Always close request body even for error status codes. [#16207](https://github.com/grafana/grafana/pull/16207), [@ramongtx](https://github.com/ramongtx) +- **ManageDashboards**: Fix for checkboxes not appearing properly Firefox . [#15981](https://github.com/grafana/grafana/pull/15981), [@srid12](https://github.com/srid12) +- **Playlist**: Leaving playlist now always stops playlist . [#15791](https://github.com/grafana/grafana/pull/15791), [@peterholmberg](https://github.com/peterholmberg) +- **Prometheus**: fixes regex ad-hoc filters variables with wildcards. [#16234](https://github.com/grafana/grafana/pull/16234), [@daniellee](https://github.com/daniellee) +- **TablePanel**: Column color style now works even after removing columns. [#16227](https://github.com/grafana/grafana/pull/16227), [@torkelo](https://github.com/torkelo) +- **TablePanel**: Fix for white text on white background when value is null. [#16199](https://github.com/grafana/grafana/pull/16199), [@peterholmberg](https://github.com/peterholmberg) + +# 6.0.2 (2019-03-19) + +### Bug Fixes + +- **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo) +- **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr) +- **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr) +- **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg) +- **Data Source admin**: Fixed url of back button in data source edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop) + +# 6.0.1 (2019-03-06) + +### Bug Fixes + +- **Metrics**: Fixes broken usagestats metrics for /metrics [#15651](https://github.com/grafana/grafana/issues/15651) +- **Dashboard**: Fixes kiosk mode should have &kiosk appended to the url [#15765](https://github.com/grafana/grafana/issues/15765) +- **Dashboard**: Fixes kiosk=tv mode with autofitpanels should respect header [#15650](https://github.com/grafana/grafana/issues/15650) +- **Image rendering**: Fixed image rendering issue for dashboards with auto refresh, . [#15818](https://github.com/grafana/grafana/pull/15818), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fix only users that can edit a dashboard should be able to update panel json. [#15805](https://github.com/grafana/grafana/pull/15805), [@marefr](https://github.com/marefr) +- **LDAP**: fix allow anonymous initial bind for ldap search. [#15803](https://github.com/grafana/grafana/pull/15803), [@marefr](https://github.com/marefr) +- **UX**: Fixed scrollbar not visible initially (only after manual scroll). [#15798](https://github.com/grafana/grafana/pull/15798), [@torkelo](https://github.com/torkelo) +- **Data Source admin** TestData [#15793](https://github.com/grafana/grafana/pull/15793), [@hugohaggmark](https://github.com/hugohaggmark) +- **Dashboard**: Fixed scrolling issue that caused scroll to be locked to bottom. [#15792](https://github.com/grafana/grafana/pull/15792), [@torkelo](https://github.com/torkelo) +- **Explore**: Viewers with viewers_can_edit should be able to access /explore. [#15787](https://github.com/grafana/grafana/pull/15787), [@jschill](https://github.com/jschill) +- **Security** fix: limit access to org admin and alerting pages. [#15761](https://github.com/grafana/grafana/pull/15761), [@marefr](https://github.com/marefr) +- **Panel Edit** minInterval changes did not persist [#15757](https://github.com/grafana/grafana/pull/15757), [@hugohaggmark](https://github.com/hugohaggmark) +- **Teams**: Fixed bug when getting teams for user. [#15595](https://github.com/grafana/grafana/pull/15595), [@hugohaggmark](https://github.com/hugohaggmark) +- **Stackdriver**: fix for float64 bounds for distribution metrics [#14509](https://github.com/grafana/grafana/issues/14509) +- **Stackdriver**: no reducers available for distribution type [#15179](https://github.com/grafana/grafana/issues/15179) + +# 6.0.0 stable (2019-02-25) + +### Bug Fixes + +- **Dashboard**: fixes click after scroll in series override menu [#15621](https://github.com/grafana/grafana/issues/15621) +- **MySQL**: fix mysql query using \_interval_ms variable throws error [#14507](https://github.com/grafana/grafana/issues/14507) + +# 6.0.0-beta3 (2019-02-19) + +### Minor + +- **CLI**: Grafana CLI should preserve permissions for backend binaries for Linux and Darwin [#15500](https://github.com/grafana/grafana/issues/15500) +- **Alerting**: Allow image rendering 90 percent of alertTimeout [#15395](https://github.com/grafana/grafana/pull/15395) + +### Bug fixes + +- **Influxdb**: Add support for alerting on InfluxDB queries that use the non_negative_difference function [#15415](https://github.com/grafana/grafana/issues/15415), thx [@kiran3394](https://github.com/kiran3394) +- **Alerting**: Fix percent_diff calculation when points are nulls [#15443](https://github.com/grafana/grafana/issues/15443), thx [@max-neverov](https://github.com/max-neverov) +- **Alerting**: Fixed handling of alert urls with true flags [#15454](https://github.com/grafana/grafana/issues/15454) + +# 6.0.0-beta2 (2019-02-11) + +### New Features + +- **AzureMonitor**: Enable alerting by converting Azure Monitor API to Go [#14623](https://github.com/grafana/grafana/issues/14623) + +### Minor + +- **Alerting**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae) +- **Graphite/InfluxDB/OpenTSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges [#15284](https://github.com/grafana/grafana/issues/15284) +- **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) +- **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) +- **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) +- **Cloudwatch**: Add AWS/EC2/API metrics [#14233](https://github.com/grafana/grafana/issues/14233), thx [@tcpatterson](https://github.com/tcpatterson) +- **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) +- **MySQL**: Adds data source SSL CA/client certificates support [#8570](https://github.com/grafana/grafana/issues/8570), thx [@bugficks](https://github.com/bugficks) +- **MSSQL**: Timerange are now passed for template variable queries [#13324](https://github.com/grafana/grafana/issues/13324), thx [@thatsparesh](https://github.com/thatsparesh) +- **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) +- **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda) +- **Login**: Anonymous usage stats for token auth [#15288](https://github.com/grafana/grafana/issues/15288) +- **AzureMonitor**: improve autocomplete for Log Analytics and App Insights editor [#15131](https://github.com/grafana/grafana/issues/15131) +- **LDAP**: Fix IPA/FreeIPA v4.6.4 does not allow LDAP searches with empty attributes [#14432](https://github.com/grafana/grafana/issues/14432) +- **Provisioning**: Allow testing data sources that were added by config [#12164](https://github.com/grafana/grafana/issues/12164) +- **Security**: Fix CSRF Token validation for POSTs [#1441](https://github.com/grafana/grafana/issues/1441) + +### Breaking changes + +- **Internal Metrics** Edition has been added to the build_info metric. This will break any Graphite queries using this metric. Edition will be a new label for the Prometheus metric. [#15363](https://github.com/grafana/grafana/pull/15363) + +### Bug fixes + +- **Gauge**: Fix issue with gauge requests being cancelled [#15366](https://github.com/grafana/grafana/issues/15366) +- **Gauge**: Accept decimal inputs for thresholds [#15372](https://github.com/grafana/grafana/issues/15372) +- **UI**: Fix error caused by named colors that are not part of named colors palette [#15373](https://github.com/grafana/grafana/issues/15373) +- **Search**: Bug pressing special regexp chars in input fields [#12972](https://github.com/grafana/grafana/issues/12972) +- **Permissions**: No need to have edit permissions to be able to "Save as" [#13066](https://github.com/grafana/grafana/issues/13066) + +# 6.0.0-beta1 (2019-01-30) + +### New Features + +- **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) +- **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968) +- **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl) +- **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109) +- **Provisioning**: Provisioning support for alert notifiers [#10487](https://github.com/grafana/grafana/issues/10487), thx [@pbakulev](https://github.com/pbakulev) +- **Explore**: A whole new way to do ad-hoc metric queries and exploration. Split view in half and compare metrics & logs and much much more. [Read more here](http://docs.grafana.org/features/explore/) +- **Auth**: Replace remember me cookie solution for Grafana's builtin, LDAP and OAuth authentication with a solution based on short-lived tokens [#15303](https://github.com/grafana/grafana/issues/15303) + +### Minor + +- **Templating**: Built in time range variables `$__from` and `$__to`, [#1909](https://github.com/grafana/grafana/issues/1909) +- **Alerting**: Use separate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813) +- **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) +- **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175) +- **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh) +- **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh) +- **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok) +- **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551) +- **Postgres/MySQL/MSSQL**: Nanosecond timestamp support (`$__unixEpochNanoFilter`, `$__unixEpochNanoFrom`, `$__unixEpochNanoTo`) [#14711](https://github.com/grafana/grafana/pull/14711), thx [@ander26](https://github.com/ander26) +- **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) +- **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) +- **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) +- **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483) +- **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548) +- **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas) +- **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard) +- **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud) +- **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia) +- **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK) +- **Templating**: Add percentencode formatting to variable interpolation to be used mainly for url escaping [#12764](https://github.com/grafana/grafana/issues/12764), thx [@cxcv](https://github.com/cxcv) +- **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik) +- **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo) +- **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484) +- **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) +- **Dataproxy**: Add global data source proxy timeout setting [#5699](https://github.com/grafana/grafana/issues/5699), thx [@RangerRick](https://github.com/RangerRick) +- **Database**: Support specifying database host using IPV6 for backend database and sql data sources [#13711](https://github.com/grafana/grafana/issues/13711), thx [@ellisvlad](https://github.com/ellisvlad) +- **Database**: Support defining additional database connection string args when using `url` property in database settings [#14709](https://github.com/grafana/grafana/pull/14709), thx [@tpetr](https://github.com/tpetr) +- **Stackdriver**: crossSeriesAggregation not being sent with the query [#15129](https://github.com/grafana/grafana/issues/15129), thx [@Legogris](https://github.com/Legogris) + +### Bug fixes + +- **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486) +- **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795) +- **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy) +- **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062) +- **Postgres**: Fix default port not added when port not configured [#15189](https://github.com/grafana/grafana/issues/15189) +- **Alerting**: Fixes crash bug when alert notifier folders are missing [#15295](https://github.com/grafana/grafana/issues/15295) +- **Dashboard**: Fix save provisioned dashboard modal [#15219](https://github.com/grafana/grafana/pull/15219) +- **Dashboard**: Fix having a long query in prometheus dashboard query editor blocks 30% of the query field when on OSX and having native scrollbars [#15122](https://github.com/grafana/grafana/issues/15122) +- **Explore**: Fix issue with wrapping on long queries [#15222](https://github.com/grafana/grafana/issues/15222) +- **Explore**: Fix cut & paste adds newline before and after selection [#15223](https://github.com/grafana/grafana/issues/15223) +- **Dataproxy**: Fix global data source proxy timeout not added to correct http client [#15258](https://github.com/grafana/grafana/issues/15258) [#5699](https://github.com/grafana/grafana/issues/5699) + +### Breaking changes + +- **Text Panel**: The text panel does no longer by default allow unsanitized HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`. +- **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991) + +For older release notes, refer to the [CHANGELOG_ARCHIVE.md](https://github.com/grafana/grafana/blob/master/CHANGELOG_ARCHIVE.md) diff --git a/CHANGELOG_ARCHIVE.md b/CHANGELOG_ARCHIVE.md new file mode 100644 index 0000000..68d0efd --- /dev/null +++ b/CHANGELOG_ARCHIVE.md @@ -0,0 +1,2257 @@ + +# 5.4.5 (2019-08-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/08/29/grafana-5.4.5-and-6.3.4-released-with-important-security-fix/) + +# 5.4.4 (2019-04-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/04/29/grafana-5.4.4-and-6.1.6-released-with-important-security-fix/) + +# 5.4.3 (2019-01-14) + +### Tech + +- **Docker**: Build and publish docker images for armv7 and arm64 [#14617](https://github.com/grafana/grafana/pull/14617), thx [@johanneswuerbach](https://github.com/johanneswuerbach) +- **Backend**: Upgrade to golang 1.11.4 [#14580](https://github.com/grafana/grafana/issues/14580) +- **MySQL** only update session in mysql database when required [#14540](https://github.com/grafana/grafana/pull/14540) + +### Bug fixes + +- **Alerting** Invalid frequency causes division by zero in alert scheduler [#14810](https://github.com/grafana/grafana/issues/14810) +- **Dashboard** Dashboard links do not update when time range changes [#14493](https://github.com/grafana/grafana/issues/14493) +- **Limits** Support more than 1000 data sources per org [#13883](https://github.com/grafana/grafana/issues/13883) +- **Backend** fix signed in user for orgId=0 result should return active org id [#14574](https://github.com/grafana/grafana/pull/14574) +- **Provisioning** Adds orgId to user dto for provisioned dashboards [#14678](https://github.com/grafana/grafana/pull/14678) + +# 5.4.2 (2018-12-13) + +- **Data Source admin**: Fix for issue creating new data source when same name exists [#14467](https://github.com/grafana/grafana/issues/14467) +- **OAuth**: Fix for oauth auto login setting, can now be set using env variable [#14435](https://github.com/grafana/grafana/issues/14435) +- **Dashboard search**: Fix for searching tags in tags filter dropdown. + +# 5.4.1 (2018-12-10) + +- **Stackdriver**: Fixes issue with data proxy and Authorization header [#14262](https://github.com/grafana/grafana/issues/14262) +- **Units**: fixedUnit for Flow:l/min and mL/min [#14294](https://github.com/grafana/grafana/issues/14294), thx [@flopp999](https://github.com/flopp999). +- **Logging**: Fix for issue where data proxy logged a secret when debug logging was enabled, now redacted. [#14319](https://github.com/grafana/grafana/issues/14319) +- TSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti) +- **Plugins**: Panel plugins should no receive the panel-initialized event again as usual. +- **Embedded Graphs**: Iframe graph panels should now work as usual. [#14284](https://github.com/grafana/grafana/issues/14284) +- **Postgres**: Improve PostgreSQL Query Editor if using different Schemas, [#14313](https://github.com/grafana/grafana/pull/14313) +- **Quotas**: Fixed for updating org & user quotas. [#14347](https://github.com/grafana/grafana/pull/14347), thx [#moznion](https://github.com/moznion) +- **Cloudwatch**: Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate to auto complete list. [#14401](https://github.com/grafana/grafana/pull/14401), thx [@sglajchEG](https://github.com/sglajchEG) +- **Dashboard Search**: Fixed filtering by tag issues. +- **Graph**: Fixed time region issues, [#14425](https://github.com/grafana/grafana/issues/14425), [#14280](https://github.com/grafana/grafana/issues/14280) +- **Graph**: Fixed issue with series color picker popover being placed outside window. + +# 5.4.0 (2018-12-03) + +- **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150) +- **Cloudwatch**: AWS/CodeBuild metrics and dimensions [#14167](https://github.com/grafana/grafana/issues/14167), thx [@mmcoltman](https://github.com/mmcoltman) +- **MySQL**: Fix `$__timeFrom()` and `$__timeTo()` should respect local time zone [#14228](https://github.com/grafana/grafana/issues/14228) + +### 5.4.0-beta1 fixes + +- **Graph**: Fix legend always visible even if configured to be hidden [#14144](https://github.com/grafana/grafana/issues/14144) +- **Elasticsearch**: Fix regression when using data source version 6.0+ and alerting [#14175](https://github.com/grafana/grafana/pull/14175) + +# 5.4.0-beta1 (2018-11-20) + +### New Features + +- **Alerting**: Introduce alert debouncing with the `FOR` setting. [#7886](https://github.com/grafana/grafana/issues/7886) & [#6202](https://github.com/grafana/grafana/issues/6202) +- **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) +- **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) +- **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm) +- **MySQL**: Support connecting thru Unix socket for MySQL data source [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +- **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) +- **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669) +- **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550) +- **Graph**: Time regions support enabling highlight of weekdays and/or certain timespans [#5930](https://github.com/grafana/grafana/issues/5930) +- **OAuth**: Automatic redirect to sign-in with OAuth [#11893](https://github.com/grafana/grafana/issues/11893), thx [@Nick-Triller](https://github.com/Nick-Triller) +- **Stackdriver**: Template query editor [#13561](https://github.com/grafana/grafana/issues/13561) + +### Minor + +- **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043) +- **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy) +- **Cloudwatch**: CloudHSM metrics and dimensions [#14129](https://github.com/grafana/grafana/pull/14129), thx [@daktari](https://github.com/daktari) +- **Cloudwatch**: Enable using variables in the stats field [#13810](https://github.com/grafana/grafana/issues/13810), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm) +- **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367) +- **Elasticsearch**: Fix deprecation warning about terms aggregation order key in Elasticsearch 6.x [#11977](https://github.com/grafana/grafana/issues/11977) +- **Graph**: Render dots when no connecting line can be made [#13605](https://github.com/grafana/grafana/issues/13605), thx [@jsferrei](https://github.com/jsferrei) +- **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme) +- **Singlestat**: Fix XSS in prefix/postfix [#13946](https://github.com/grafana/grafana/issues/13946), thx [@cinaglia](https://github.com/cinaglia) +- **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) +- **Alerting**: Increase default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945) +- **Alerting**: More options for the Slack Alert notifier [#13993](https://github.com/grafana/grafana/issues/13993), thx [@andreykaipov](https://github.com/andreykaipov) +- **Alerting**: 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) +- **Alerting**: Increase Telegram captions length limit [#13876](https://github.com/grafana/grafana/pull/13876), thx [@skgsergio](https://github.com/skgsergio) +- **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876) +- **Data Source Proxy**: Keep trailing slash for data source proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +- **OAuth**: Fix Google OAuth relies on email, not google account id [#13924](https://github.com/grafana/grafana/issues/13924), thx [@vinicyusmacedo](https://github.com/vinicyusmacedo) +- **Dashboard**: Toggle legend using keyboard shortcut [#13655](https://github.com/grafana/grafana/issues/13655), thx [@davewat](https://github.com/davewat) +- **Dashboard**: Fix render dashboard row drag handle only in edit mode [#13555](https://github.com/grafana/grafana/issues/13555), thx [@praveensastry](https://github.com/praveensastry) +- **Teams**: Fix cannot select team if not included in initial search [#13425](https://github.com/grafana/grafana/issues/13425) +- **Render**: Support full height screenshots using phantomjs render script [#13352](https://github.com/grafana/grafana/pull/13352), thx [@amuraru](https://github.com/amuraru) +- **HTTP API**: Support retrieving teams by user [#14120](https://github.com/grafana/grafana/pull/14120), thx [@supercharlesliu](https://github.com/supercharlesliu) +- **Metrics**: Add basic authentication to metrics endpoint [#13577](https://github.com/grafana/grafana/issues/13577), thx [@bobmshannon](https://github.com/bobmshannon) + +### Breaking changes + +- Postgres/MySQL/MSSQL data sources 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.4 (2018-11-13) + +- **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322) +- **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769) +- **Dashboard**: Fix data source selection in panel by enter key [#13932](https://github.com/grafana/grafana/issues/13932) +- **Graph**: Fix table legend height when positioned below graph and using Internet Explorer 11 [#13903](https://github.com/grafana/grafana/issues/13903) +- **Dataproxy**: Drop origin and referer http headers [#13328](https://github.com/grafana/grafana/issues/13328) [#13949](https://github.com/grafana/grafana/issues/13949), thx [@roidelapluie](https://github.com/roidelapluie) + +# 5.3.3 (2018-11-13) + +### File Exfiltration vulnerability Security fix + +See [security announcement](https://community.grafana.com/t/grafana-5-3-3-and-4-6-5-security-update/11961) for details. + +# 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 data source response doesn't include unit [#13718](https://github.com/grafana/grafana/issues/13718), thx [@mtanda](https://github.com/mtanda) +- **Stackdriver**: stackdriver user-metrics duplicated response when multiple resource types [#13691](https://github.com/grafana/grafana/issues/13691) +- **Variables**: Fix text box template variable doesn't work properly without a default value [#13666](https://github.com/grafana/grafana/issues/13666) +- **Variables**: Fix variable dependency check when using `${var}` format [#13600](https://github.com/grafana/grafana/issues/13600) +- **Dashboard**: Fix kiosk=1 url parameter should put dashboard in kiosk mode [#13764](https://github.com/grafana/grafana/pull/13764) +- **LDAP**: Fix super admins can also be admins of orgs [#13710](https://github.com/grafana/grafana/issues/13710), thx [@adrien-f](https://github.com/adrien-f) +- **Provisioning**: Fix deleting provisioned dashboard folder should cleanup provisioning meta data [#13280](https://github.com/grafana/grafana/issues/13280) + +### Minor + +- **Docker**: adds curl back into the docker image for utility. [#13794](https://github.com/grafana/grafana/pull/13794) + +# 5.3.1 (2018-10-16) + +- **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616) +- **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607) +- **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +- **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553) +- **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628) +- **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky) +- **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650) +- **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech) +- **HTTP API**: Fix /api/org/users so that query and limit querystrings works + +# 5.3.0 (2018-10-10) + +- **Stackdriver**: Filter wildcards and regex matching are not yet supported [#13495](https://github.com/grafana/grafana/issues/13495) +- **Stackdriver**: Support the distribution metric type for heatmaps [#13559](https://github.com/grafana/grafana/issues/13559) +- **Cloudwatch**: Automatically set graph yaxis unit [#13575](https://github.com/grafana/grafana/issues/13575), thx [@mtanda](https://github.com/mtanda) + +# 5.3.0-beta3 (2018-10-03) + +- **Stackdriver**: Fix for missing ngInject [#13511](https://github.com/grafana/grafana/pull/13511) +- **Permissions**: Fix for broken permissions selector [#13507](https://github.com/grafana/grafana/issues/13507) +- **Alerting**: Alert reminders deduping not working as expected when running multiple Grafana instances [#13492](https://github.com/grafana/grafana/issues/13492) + +# 5.3.0-beta2 (2018-10-01) + +### New Features + +- **Annotations**: Enable template variables in tagged annotations queries [#9735](https://github.com/grafana/grafana/issues/9735) +- **Stackdriver**: Support for Google Stackdriver data source [#13289](https://github.com/grafana/grafana/pull/13289) + +### Minor + +- **Provisioning**: Dashboard Provisioning now support symlinks that changes target [#12534](https://github.com/grafana/grafana/issues/12534), thx [@auhlig](https://github.com/auhlig) +- **OAuth**: Allow oauth email attribute name to be configurable [#12986](https://github.com/grafana/grafana/issues/12986), thx [@bobmshannon](https://github.com/bobmshannon) +- **Tags**: Default sort order for GetDashboardTags [#11681](https://github.com/grafana/grafana/pull/11681), thx [@Jonnymcc](https://github.com/Jonnymcc) +- **Prometheus**: Label completion queries respect dashboard time range [#12251](https://github.com/grafana/grafana/pull/12251), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Allow to display annotations based on Prometheus series value [#10159](https://github.com/grafana/grafana/issues/10159), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Adhoc-filtering for Prometheus dashboards [#13212](https://github.com/grafana/grafana/issues/13212) +- **Singlestat**: Fix gauge display accuracy for percents [#13270](https://github.com/grafana/grafana/issues/13270), thx [@tianon](https://github.com/tianon) +- **Dashboard**: Prevent auto refresh from starting when loading dashboard with absolute time range [#12030](https://github.com/grafana/grafana/issues/12030) +- **Templating**: New templating variable type `Text box` that allows free text input [#3173](https://github.com/grafana/grafana/issues/3173) +- **Alerting**: Link to view full size image in Microsoft Teams alert notifier [#13121](https://github.com/grafana/grafana/issues/13121), thx [@holiiveira](https://github.com/holiiveira) +- **Alerting**: Fixes a bug where all alerts would send reminders after upgrade & restart [#13402](https://github.com/grafana/grafana/pull/13402) +- **Alerting**: Concurrent render limit for graphs used in notifications [#13401](https://github.com/grafana/grafana/pull/13401) +- **Postgres/MySQL/MSSQL**: Add support for replacing $__interval and $\_\_interval_ms in alert queries [#11555](https://github.com/grafana/grafana/issues/11555), thx [@svenklemm](https://github.com/svenklemm) + +# 5.3.0-beta1 (2018-09-06) + +### New Major Features + +- **Alerting**: Notification reminders [#7330](https://github.com/grafana/grafana/issues/7330), thx [@jbaublitz](https://github.com/jbaublitz) +- **Dashboard**: TV & Kiosk mode changes, new cycle view mode button in dashboard toolbar [#13025](https://github.com/grafana/grafana/pull/13025) +- **OAuth**: Gitlab OAuth with support for filter by groups [#5623](https://github.com/grafana/grafana/issues/5623), thx [@BenoitKnecht](https://github.com/BenoitKnecht) +- **Postgres**: Graphical query builder [#10095](https://github.com/grafana/grafana/issues/10095), thx [svenklemm](https://github.com/svenklemm) + +### New Features + +- **LDAP**: Define Grafana Admin permission in ldap group mappings [#2469](https://github.com/grafana/grafana/issues/2496), PR [#12622](https://github.com/grafana/grafana/issues/12622) +- **LDAP**: Client certificates support [#12805](https://github.com/grafana/grafana/issues/12805), thx [@nyxi](https://github.com/nyxi) +- **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476) +- **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos) +- **Dataproxy**: Pass configured/auth headers to a data source [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano) +- **CloudWatch**: GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm) +- **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon) + +### Minor + +- **Alerting**: Its now possible to configure the default value for how to handle errors and no data in alerting. [#10424](https://github.com/grafana/grafana/issues/10424) +- **Alerting**: Fix diff and percent_diff reducers [#11563](https://github.com/grafana/grafana/issues/11563), thx [@jessetane](https://github.com/jessetane) +- **Alerting**: Fix rendering timeout which could cause notifications to not be sent due to rendering timing out [#12151](https://github.com/grafana/grafana/issues/12151) +- **Docker**: Make it possible to set a specific plugin url [#12861](https://github.com/grafana/grafana/pull/12861), thx [ClementGautier](https://github.com/ClementGautier) +- **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) +- **Provisioning**: Should allow one default data source per organization [#12229](https://github.com/grafana/grafana/issues/12229) +- **GitHub OAuth**: Allow changes of user info at GitHub to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber) +- **OAuth**: Fix overriding tls_skip_verify_insecure using environment variable [#12747](https://github.com/grafana/grafana/issues/12747), thx [@jangaraj](https://github.com/jangaraj) +- **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) +- **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484) +- **Prometheus**: Add $__interval, $**interval_ms, \$**range, $__range_s & $\_\_range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597) [#12882](https://github.com/grafana/grafana/issues/12882), thx [@roidelapluie](https://github.com/roidelapluie) +- **Elasticsearch**: For alerting/backend, support having index name to the right of pattern in index pattern [#12731](https://github.com/grafana/grafana/issues/12731) +- **Graphite**: Fix for quoting of int function parameters (when using variables) [#11927](https://github.com/grafana/grafana/pull/11927) +- **InfluxDB**: Support timeFilter in query templating for InfluxDB [#12598](https://github.com/grafana/grafana/pull/12598), thx [kichristensen](https://github.com/kichristensen) +- **Postgres/MySQL/MSSQL**: New $__unixEpochGroup and $\_\_unixEpochGroupAlias macros [#12892](https://github.com/grafana/grafana/issues/12892), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Add previous fill mode to \$\_\_timeGroup macro which will fill in previously seen value when point is missing [#12756](https://github.com/grafana/grafana/issues/12756), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Use floor rounding in \$\_\_timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Use metric column as prefix when returning multiple value columns [#12727](https://github.com/grafana/grafana/issues/12727), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: New $__timeGroupAlias macro. Postgres $\_\_timeGroup no longer automatically adds time column alias [#12749](https://github.com/grafana/grafana/issues/12749), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Escape single quotes in variables [#12785](https://github.com/grafana/grafana/issues/12785), thx [@eMerzh](https://github.com/eMerzh) +- **Postgres/MySQL/MSSQL**: Min time interval support [#13157](https://github.com/grafana/grafana/issues/13157), thx [@svenklemm](https://github.com/svenklemm) +- **MySQL/MSSQL**: Use datetime format instead of epoch for $__timeFilter, $**timeFrom and \$**timeTo macros [#11618](https://github.com/grafana/grafana/issues/11618) [#11619](https://github.com/grafana/grafana/issues/11619), thx [@AustinWinstanley](https://github.com/AustinWinstanley) +- **Postgres**: Escape ssl mode parameter in connectionstring [#12644](https://github.com/grafana/grafana/issues/12644), thx [@yogyrahmawan](https://github.com/yogyrahmawan) +- **Cloudwatch**: Improved error handling [#12489](https://github.com/grafana/grafana/issues/12489), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: AppSync metrics and dimensions [#12300](https://github.com/grafana/grafana/issues/12300), thx [@franciscocpg](https://github.com/franciscocpg) +- **Cloudwatch**: Direct Connect metrics and dimensions [#12762](https://github.com/grafana/grafana/pulls/12762), thx [@mindriot88](https://github.com/mindriot88) +- **Cloudwatch**: Added BurstBalance metric to list of AWS RDS metrics [#12561](https://github.com/grafana/grafana/pulls/12561), thx [@activeshadow](https://github.com/activeshadow) +- **Cloudwatch**: Add new Redshift metrics and dimensions [#12063](https://github.com/grafana/grafana/pulls/12063), thx [@A21z](https://github.com/A21z) +- **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248) +- **Dashboard**: Use uid when linking to dashboards internally in a dashboard [#10705](https://github.com/grafana/grafana/issues/10705) +- **Graph**: Option to hide series from tooltip [#3341](https://github.com/grafana/grafana/issues/3341), thx [@mtanda](https://github.com/mtanda) +- **Singlestat**: Make colorization of prefix and postfix optional in singlestat [#11892](https://github.com/grafana/grafana/pull/11892), thx [@ApsOps](https://github.com/ApsOps) +- **Table**: Adjust header contrast for the light theme [#12668](https://github.com/grafana/grafana/issues/12668) +- **Table**: Fix link color when using light theme and thresholds in use [#12766](https://github.com/grafana/grafana/issues/12766) +- **Table**: Fix for useless horizontal scrollbar for table panel [#9964](https://github.com/grafana/grafana/issues/9964) +- **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2) +- **Heatmap**: Fix broken tooltip and crosshair on Firefox [#12486](https://github.com/grafana/grafana/issues/12486) +- **Data Source**: Fix UI issue with secret fields after updating data source [#11270](https://github.com/grafana/grafana/issues/11270) +- **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda) +- **Variables**: Limit amount of queries executed when updating variable that other variable(s) are dependent on [#11890](https://github.com/grafana/grafana/issues/11890) +- **Variables**: Support query variable refresh when another variable referenced in `Regex` field change its value [#12952](https://github.com/grafana/grafana/issues/12952), thx [@franciscocpg](https://github.com/franciscocpg) +- **Variables**: Support variables in query variable `Custom all value` field [#12965](https://github.com/grafana/grafana/issues/12965), thx [@franciscocpg](https://github.com/franciscocpg) +- **Units**: Change units to include characters for power of 2 and 3 [#12744](https://github.com/grafana/grafana/pull/12744), thx [@Worty](https://github.com/Worty) +- **Units**: Polish złoty currency [#12691](https://github.com/grafana/grafana/pull/12691), thx [@mwegrzynek](https://github.com/mwegrzynek) +- **Units**: Adds bitcoin axes unit. [#13125](https://github.com/grafana/grafana/pull/13125) +- **Api**: Delete nonexistent data source should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley) +- **Logging**: Reopen log files after receiving a SIGHUP signal [#13112](https://github.com/grafana/grafana/pull/13112), thx [@filewalkwithme](https://github.com/filewalkwithme) +- **Login**: Show loading animation while waiting for authentication response on login [#12865](https://github.com/grafana/grafana/issues/12865) +- **UI**: Fix iOS home screen "app" icon and Windows 10 app experience [#12752](https://github.com/grafana/grafana/issues/12752), thx [@andig](https://github.com/andig) +- **Plugins**: Convert URL-like text to links in plugins readme [#12843](https://github.com/grafana/grafana/pull/12843), thx [pgiraud](https://github.com/pgiraud) + +### Breaking changes + +- Postgres data source no longer automatically adds time column alias when using the \$\_\_timeGroup alias. However, there's code in place which should make this change backward compatible and shouldn't create any issues. +- Kiosk mode now also hides submenu (variables) +- ?inactive url parameter no longer supported, replaced with kiosk=tv url parameter + +### New experimental features + +These are new features that's still being worked on and are in an experimental phase. We encourage users to try these out and provide any feedback in related issue. + +- **Dashboard**: Auto fit dashboard panels to optimize space used for current TV / Monitor [#12768](https://github.com/grafana/grafana/issues/12768) + +### Tech + +- **Frontend**: Convert all Frontend Karma tests to Jest tests [#12224](https://github.com/grafana/grafana/issues/12224) +- **Backend**: Upgrade to golang 1.11 [#13030](https://github.com/grafana/grafana/issues/13030) + +# 5.2.4 (2018-09-07) + +- **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) + +# 5.2.3 (2018-08-29) + +### Important fix for LDAP & OAuth login vulnerability + +See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4-6-4-security-update/10050) for details. + +# 5.2.2 (2018-07-25) + +### Minor + +- **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) +- **Dashboard**: Dashboard links not updated when changing variables [#12506](https://github.com/grafana/grafana/issues/12506) +- **Postgres/MySQL/MSSQL**: Fix connection leak [#12636](https://github.com/grafana/grafana/issues/12636) [#9827](https://github.com/grafana/grafana/issues/9827) +- **Plugins**: Fix loading of external plugins [#12551](https://github.com/grafana/grafana/issues/12551) +- **Dashboard**: Remove unwanted scrollbars in embedded panels [#12589](https://github.com/grafana/grafana/issues/12589) +- **Prometheus**: Prevent error using \$\_\_interval_ms in query [#12533](https://github.com/grafana/grafana/pull/12533), thx [@mtanda](https://github.com/mtanda) + +# 5.2.1 (2018-06-29) + +### Minor + +- **Auth Proxy**: Important security fix for whitelist of IP address feature [#12444](https://github.com/grafana/grafana/pull/12444) +- **UI**: Fix - Grafana footer overlapping page [#12430](https://github.com/grafana/grafana/issues/12430) +- **Logging**: Errors should be reported before crashing [#12438](https://github.com/grafana/grafana/issues/12438) + +# 5.2.0-stable (2018-06-27) + +### Minor + +- **Plugins**: Handle errors correctly when loading data source plugin [#12383](https://github.com/grafana/grafana/pull/12383) thx [@rozetko](https://github.com/rozetko) +- **Render**: Enhance error message if phantomjs executable is not found [#11868](https://github.com/grafana/grafana/issues/11868) +- **Dashboard**: Set correct text in drop down when variable is present in url [#11968](https://github.com/grafana/grafana/issues/11968) + +### 5.2.0-beta3 fixes + +- **LDAP**: Handle "dn" ldap attribute more gracefully [#12385](https://github.com/grafana/grafana/pull/12385), reverts [#10970](https://github.com/grafana/grafana/pull/10970) + +# 5.2.0-beta3 (2018-06-21) + +### Minor + +- **Build**: All rpm packages should be signed [#12359](https://github.com/grafana/grafana/issues/12359) + +# 5.2.0-beta2 (2018-06-20) + +### New Features + +- **Dashboard**: Import dashboard to folder [#10796](https://github.com/grafana/grafana/issues/10796) + +### Minor + +- **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343) +- **Dashboard**: Fix so panel titles doesn't wrap [#11074](https://github.com/grafana/grafana/issues/11074) +- **Dashboard**: Prevent double-click when saving dashboard [#11963](https://github.com/grafana/grafana/issues/11963) +- **Dashboard**: AutoFocus the add-panel search filter [#12189](https://github.com/grafana/grafana/pull/12189) thx [@ryantxu](https://github.com/ryantxu) +- **Units**: W/m2 (energy), l/h (flow) and kPa (pressure) [#11233](https://github.com/grafana/grafana/pull/11233), thx [@flopp999](https://github.com/flopp999) +- **Units**: Liter/min (flow) and milliLiter/min (flow) [#12282](https://github.com/grafana/grafana/pull/12282), thx [@flopp999](https://github.com/flopp999) +- **Alerting**: Fix mobile notifications for Microsoft Teams alert notifier [#11484](https://github.com/grafana/grafana/pull/11484), thx [@manacker](https://github.com/manacker) +- **Influxdb**: Add support for mode function [#12286](https://github.com/grafana/grafana/issues/12286) +- **Cloudwatch**: Fixes panic caused by bad timerange settings [#12199](https://github.com/grafana/grafana/issues/12199) +- **Auth Proxy**: Whitelist proxy IP address instead of client IP address [#10707](https://github.com/grafana/grafana/issues/10707) +- **User Management**: Make sure that a user always has a current org assigned [#11076](https://github.com/grafana/grafana/issues/11076) +- **Snapshots**: Fix: annotations not properly extracted leading to incorrect rendering of annotations [#12278](https://github.com/grafana/grafana/issues/12278) +- **LDAP**: Allow use of DN in group_search_filter_user_attribute and member_of [#3132](https://github.com/grafana/grafana/issues/3132), thx [@mmolnar](https://github.com/mmolnar) +- **Graph**: Fix legend decimals precision calculation [#11792](https://github.com/grafana/grafana/issues/11792) +- **Dashboard**: Make sure to process panels in collapsed rows when exporting dashboard [#12256](https://github.com/grafana/grafana/issues/12256) + +### 5.2.0-beta1 fixes + +- **Dashboard**: Dashboard link doesn't work when "As dropdown" option is checked [#12315](https://github.com/grafana/grafana/issues/12315) +- **Dashboard**: Fix regressions after save modal changes, including adhoc template issues [#12240](https://github.com/grafana/grafana/issues/12240) +- **Docker**: Config keys ending with \_FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170) + +# 5.2.0-beta1 (2018-06-05) + +### New Features + +- **Elasticsearch**: Alerting support [#5893](https://github.com/grafana/grafana/issues/5893), thx [@WPH95](https://github.com/WPH95) +- **Build**: Crosscompile and packages Grafana on arm, windows, linux and darwin [#11920](https://github.com/grafana/grafana/pull/11920), thx [@fg2it](https://github.com/fg2it) +- **Login**: Change admin password after first login [#11882](https://github.com/grafana/grafana/issues/11882) +- **Alert list panel**: Updated to support filtering alerts by name, dashboard title, folder, tags [#11500](https://github.com/grafana/grafana/issues/11500), [#8168](https://github.com/grafana/grafana/issues/8168), [#6541](https://github.com/grafana/grafana/issues/6541) + +### Minor + +- **Dashboard**: Modified time range and variables are now not saved by default [#10748](https://github.com/grafana/grafana/issues/10748), [#8805](https://github.com/grafana/grafana/issues/8805) +- **Graph**: Show invisible highest value bucket in histogram [#11498](https://github.com/grafana/grafana/issues/11498) +- **Dashboard**: Enable "Save As..." if user has edit permission [#11625](https://github.com/grafana/grafana/issues/11625) +- **Prometheus**: Query dates are now step-aligned [#10434](https://github.com/grafana/grafana/pull/10434) +- **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda) +- **Variables**: Fix variable interpolation when using multiple formatting types [#11800](https://github.com/grafana/grafana/issues/11800), thx [@svenklemm](https://github.com/svenklemm) +- **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616) +- **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel), +- **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013) +- **InfluxDB**: Support count distinct aggregation [#11645](https://github.com/grafana/grafana/issues/11645), thx [@kichristensen](https://github.com/kichristensen) +- **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel) +- **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813) +- **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338) +- **Prometheus**: Add support for passing timeout parameter to Prometheus [#11788](https://github.com/grafana/grafana/pull/11788), thx [@mtanda](https://github.com/mtanda) +- **Login**: Add optional option sign out url for generic oauth [#9847](https://github.com/grafana/grafana/issues/9847), thx [@roidelapluie](https://github.com/roidelapluie) +- **Login**: Use proxy server from environment variable if available [#9703](https://github.com/grafana/grafana/issues/9703), thx [@iyeonok](https://github.com/iyeonok) +- **Invite users**: Friendlier error message when smtp is not configured [#12087](https://github.com/grafana/grafana/issues/12087), thx [@thurt](https://github.com/thurt) +- **Graphite**: Don't send distributed tracing headers when using direct/browser access mode [#11494](https://github.com/grafana/grafana/issues/11494) +- **Sidenav**: Show create dashboard link for viewers if at least editor in one folder [#11858](https://github.com/grafana/grafana/issues/11858) +- **SQL**: Second epochs are now correctly converted to ms. [#12085](https://github.com/grafana/grafana/pull/12085) +- **Singlestat**: Fix singlestat threshold tooltip [#11971](https://github.com/grafana/grafana/issues/11971) +- **Dashboard**: Hide grid controls in fullscreen/low-activity views [#11771](https://github.com/grafana/grafana/issues/11771) +- **Dashboard**: Validate uid when importing dashboards [#11515](https://github.com/grafana/grafana/issues/11515) +- **Docker**: Support for env variables ending with \_FILE [grafana-docker #166](https://github.com/grafana/grafana-docker/pull/166), thx [@efrecon](https://github.com/efrecon) +- **Alert list panel**: Show alerts for user with viewer role [#11167](https://github.com/grafana/grafana/issues/11167) +- **Provisioning**: Verify checksum of dashboards before updating to reduce load on database [#11670](https://github.com/grafana/grafana/issues/11670) +- **Provisioning**: Support symlinked files in dashboard provisioning config files [#11958](https://github.com/grafana/grafana/issues/11958) +- **Dashboard list panel**: Search dashboards by folder [#11525](https://github.com/grafana/grafana/issues/11525) +- **Sidenav**: Always show server admin link in sidenav if grafana admin [#11657](https://github.com/grafana/grafana/issues/11657) + +# 5.1.5 (2018-06-27) + +- **Docker**: Config keys ending with \_FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170) + +# 5.1.4 (2018-06-19) + +- **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343) + +# 5.1.3 (2018-05-16) + +- **Scroll**: Graph panel / legend texts shifts on the left each time we move scrollbar on firefox [#11830](https://github.com/grafana/grafana/issues/11830) + +# 5.1.2 (2018-05-09) + +- **Database**: Fix MySql migration issue [#11862](https://github.com/grafana/grafana/issues/11862) +- **Google Analytics**: Enable Google Analytics anonymizeIP setting for GDPR [#11656](https://github.com/grafana/grafana/pull/11656) + +# 5.1.1 (2018-05-07) + +- **LDAP**: LDAP login with MariaDB/MySQL database and dn>100 chars not possible [#11754](https://github.com/grafana/grafana/issues/11754) +- **Build**: AppVeyor Windows build missing version and commit info [#11758](https://github.com/grafana/grafana/issues/11758) +- **Scroll**: Scroll can't start in graphs on Chrome mobile [#11710](https://github.com/grafana/grafana/issues/11710) +- **Units**: Revert renaming of unit key ppm [#11743](https://github.com/grafana/grafana/issues/11743) + +# 5.1.0 (2018-04-26) + +- **Folders**: Default permissions on folder are not shown as inherited in its dashboards [#11668](https://github.com/grafana/grafana/issues/11668) +- **Templating**: Allow more than 20 previews when creating a variable [#11508](https://github.com/grafana/grafana/issues/11508) +- **Dashboard**: Row edit icon not shown [#11466](https://github.com/grafana/grafana/issues/11466) +- **SQL**: Unsupported data types for value column using time series query [#11703](https://github.com/grafana/grafana/issues/11703) +- **Prometheus**: Prometheus query inspector expands to be very large on autocomplete queries [#11673](https://github.com/grafana/grafana/issues/11673) + +# 5.1.0-beta1 (2018-04-20) + +- **MSSQL**: New Microsoft SQL Server data source [#10093](https://github.com/grafana/grafana/pull/10093), [#11298](https://github.com/grafana/grafana/pull/11298), thx [@linuxchips](https://github.com/linuxchips) +- **Prometheus**: The heatmap panel now support Prometheus histograms [#10009](https://github.com/grafana/grafana/issues/10009) +- **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Fix precision for the time column in table mode [#11306](https://github.com/grafana/grafana/issues/11306) +- **Graph**: Align left and right Y-axes to one level [#1271](https://github.com/grafana/grafana/issues/1271) & [#2740](https://github.com/grafana/grafana/issues/2740) thx [@ilgizar](https://github.com/ilgizar) +- **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar) +- **Graph**: Support multiple series stacking in histogram mode [#8151](https://github.com/grafana/grafana/issues/8151), thx [@mtanda](https://github.com/mtanda) +- **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942) +- **Alerting**: Support Pagerduty notification channel using Pagerduty V2 API [#10531](https://github.com/grafana/grafana/issues/10531), thx [@jbaublitz](https://github.com/jbaublitz) +- **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Show template variable candidate in query editor [#9210](https://github.com/grafana/grafana/issues/9210), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda) +- **Alerting**: Add support for retries on alert queries [#5855](https://github.com/grafana/grafana/issues/5855), thx [@Thib17](https://github.com/Thib17) +- **Table**: Table plugin value mappings [#7119](https://github.com/grafana/grafana/issues/7119), thx [infernix](https://github.com/infernix) +- **IE11**: IE 11 compatibility [#11165](https://github.com/grafana/grafana/issues/11165) +- **Scrolling**: Better scrolling experience [#11053](https://github.com/grafana/grafana/issues/11053), [#11252](https://github.com/grafana/grafana/issues/11252), [#10836](https://github.com/grafana/grafana/issues/10836), [#11185](https://github.com/grafana/grafana/issues/11185), [#11168](https://github.com/grafana/grafana/issues/11168) +- **Docker**: Improved docker image (breaking changes regarding file ownership) [grafana-docker #141](https://github.com/grafana/grafana-docker/issues/141), thx [@Spindel](https://github.com/Spindel), [@ChristianKniep](https://github.com/ChristianKniep), [@brancz](https://github.com/brancz) and [@jangaraj](https://github.com/jangaraj) +- **Folders**: A folder admin cannot add user/team permissions for folder/its dashboards [#11173](https://github.com/grafana/grafana/issues/11173) +- **Provisioning**: Improved workflow for provisioned dashboards [#10883](https://github.com/grafana/grafana/issues/10883) + +### Minor + +- **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes) +- **Cloudwatch**: Support high resolution metrics [#10925](https://github.com/grafana/grafana/pull/10925), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add dimension filtering to CloudWatch `dimension_values()` [#10029](https://github.com/grafana/grafana/issues/10029), thx [@willyhutw](https://github.com/willyhutw) +- **Units**: Second to HH:mm:ss formatter [#11107](https://github.com/grafana/grafana/issues/11107), thx [@gladdiologist](https://github.com/gladdiologist) +- **Singlestat**: Add color to prefix and postfix in singlestat panel [#11143](https://github.com/grafana/grafana/pull/11143), thx [@ApsOps](https://github.com/ApsOps) +- **Dashboards**: Version cleanup fails on old databases with many entries [#11278](https://github.com/grafana/grafana/issues/11278) +- **Server**: Adjust permissions of unix socket [#11343](https://github.com/grafana/grafana/pull/11343), thx [@corny](https://github.com/corny) +- **Shortcuts**: Add shortcut for duplicate panel [#11102](https://github.com/grafana/grafana/issues/11102) +- **AuthProxy**: Support IPv6 in Auth proxy white list [#11330](https://github.com/grafana/grafana/pull/11330), thx [@corny](https://github.com/corny) +- **SMTP**: Don't connect to STMP server using TLS unless configured. [#7189](https://github.com/grafana/grafana/issues/7189) +- **Prometheus**: Escape backslash in labels correctly. [#10555](https://github.com/grafana/grafana/issues/10555), thx [@roidelapluie](https://github.com/roidelapluie) +- **Variables**: Case-insensitive sorting for template values [#11128](https://github.com/grafana/grafana/issues/11128) thx [@cross](https://github.com/cross) +- **Annotations (native)**: Change default limit from 10 to 100 when querying api [#11569](https://github.com/grafana/grafana/issues/11569), thx [@flopp999](https://github.com/flopp999) +- **MySQL/Postgres/MSSQL**: PostgreSQL data source generates invalid query with dates before 1970 [#11530](https://github.com/grafana/grafana/issues/11530) thx [@ryantxu](https://github.com/ryantxu) +- **Kiosk**: Adds url parameter for starting a dashboard in inactive mode [#11228](https://github.com/grafana/grafana/issues/11228), thx [@towolf](https://github.com/towolf) +- **Dashboard**: Enable closing timepicker using escape key [#11332](https://github.com/grafana/grafana/issues/11332) +- **Data Sources**: Rename direct access mode in the data source settings [#11391](https://github.com/grafana/grafana/issues/11391) +- **Search**: Display dashboards in folder indented [#11073](https://github.com/grafana/grafana/issues/11073) +- **Units**: Use B/s instead Bps for Bytes per second [#9342](https://github.com/grafana/grafana/pull/9342), thx [@mayli](https://github.com/mayli) +- **Units**: Radiation units [#11001](https://github.com/grafana/grafana/issues/11001), thx [@victorclaessen](https://github.com/victorclaessen) +- **Units**: Timeticks unit [#11183](https://github.com/grafana/grafana/pull/11183), thx [@jtyr](https://github.com/jtyr) +- **Units**: Concentration units and "Normal cubic meter" [#11211](https://github.com/grafana/grafana/issues/11211), thx [@flopp999](https://github.com/flopp999) +- **Units**: New currency - Czech koruna [#11384](https://github.com/grafana/grafana/pull/11384), thx [@Rohlik](https://github.com/Rohlik) +- **Avatar**: Fix DISABLE_GRAVATAR option [#11095](https://github.com/grafana/grafana/issues/11095) +- **Heatmap**: Disable log scale when using time time series buckets [#10792](https://github.com/grafana/grafana/issues/10792) +- **Provisioning**: Remove `id` from json when provisioning dashboards, [#11138](https://github.com/grafana/grafana/issues/11138) +- **Prometheus**: tooltip for legend format not showing properly [#11516](https://github.com/grafana/grafana/issues/11516), thx [@svenklemm](https://github.com/svenklemm) +- **Playlist**: Empty playlists cannot be deleted [#11133](https://github.com/grafana/grafana/issues/11133), thx [@kichristensen](https://github.com/kichristensen) +- **Switch Orgs**: Alphabetic order in Switch Organization modal [#11556](https://github.com/grafana/grafana/issues/11556) +- **Postgres**: improve `$__timeFilter` macro [#11578](https://github.com/grafana/grafana/issues/11578), thx [@svenklemm](https://github.com/svenklemm) +- **Permission list**: Improved ux [#10747](https://github.com/grafana/grafana/issues/10747) +- **Dashboard**: Sizing and positioning of settings menu icons [#11572](https://github.com/grafana/grafana/pull/11572) +- **Dashboard**: Add search filter/tabs to new panel control [#10427](https://github.com/grafana/grafana/issues/10427) +- **Folders**: User with org viewer role should not be able to save/move dashboards in/to general folder [#11553](https://github.com/grafana/grafana/issues/11553) +- **Influxdb**: Don't assume the first column in table response is time. [#11476](https://github.com/grafana/grafana/issues/11476), thx [@hahnjo](https://github.com/hahnjo) + +### Tech + +- Backend code simplification [#11613](https://github.com/grafana/grafana/pull/11613), thx [@knweiss](https://github.com/knweiss) +- Add codespell to CI [#11602](https://github.com/grafana/grafana/pull/11602), thx [@mjtrangoni](https://github.com/mjtrangoni) +- Migrated JavaScript files to TypeScript + +# 5.0.4 (2018-03-28) + +- **Docker** Can't start Grafana on Kubernetes 1.7.14, 1.8.9, or 1.9.4 [#140 in grafana-docker repo](https://github.com/grafana/grafana-docker/issues/140) thx [@suquant](https://github.com/suquant) +- **Dashboard** Fixed bug where collapsed panels could not be directly linked to/renderer [#11114](https://github.com/grafana/grafana/issues/11114) & [#11086](https://github.com/grafana/grafana/issues/11086) & [#11296](https://github.com/grafana/grafana/issues/11296) +- **Dashboard** Provisioning dashboard with alert rules should create alerts [#11247](https://github.com/grafana/grafana/issues/11247) +- **Snapshots** For snapshots, the Graph panel renders the legend incorrectly on right hand side [#11318](https://github.com/grafana/grafana/issues/11318) +- **Alerting** Link back to Grafana returns wrong URL if root_path contains sub-path components [#11403](https://github.com/grafana/grafana/issues/11403) +- **Alerting** Incorrect default value for upload images setting for alert notifiers [#11413](https://github.com/grafana/grafana/pull/11413) + +# 5.0.3 (2018-03-16) + +- **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access (a bigger patch than the one in 5.0.2) [#11155](https://github.com/grafana/grafana/issues/11155) + +# 5.0.2 (2018-03-14) + +- **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access [#11155](https://github.com/grafana/grafana/issues/11155) +- **Dashboards**: Should be possible to browse dashboard using only uid [#11231](https://github.com/grafana/grafana/issues/11231) +- **Alerting**: Fixes bug where alerts from hidden panels where deleted [#11222](https://github.com/grafana/grafana/issues/11222) +- **Import**: Fixes bug where dashboards with alerts couldn't be imported [#11227](https://github.com/grafana/grafana/issues/11227) +- **Teams**: Remove quota restrictions from teams [#11220](https://github.com/grafana/grafana/issues/11220) +- **Render**: Fixes bug with legacy url redirection for panel rendering [#11180](https://github.com/grafana/grafana/issues/11180) + +# 5.0.1 (2018-03-08) + +- **Postgres**: PostgreSQL error when using ipv6 address as hostname in connection string [#11055](https://github.com/grafana/grafana/issues/11055), thanks [@svenklemm](https://github.com/svenklemm) +- **Dashboards**: Changing templated value from dropdown is causing unsaved changes [#11063](https://github.com/grafana/grafana/issues/11063) +- **Prometheus**: Fixes bundled Prometheus 2.0 dashboard [#11016](https://github.com/grafana/grafana/issues/11016), thx [@roidelapluie](https://github.com/roidelapluie) +- **Sidemenu**: Profile menu "invisible" when gravatar is disabled [#11097](https://github.com/grafana/grafana/issues/11097) +- **Dashboard**: Fixes a bug with resizable handles for panels [#11103](https://github.com/grafana/grafana/issues/11103) +- **Alerting**: Telegram inline image mode fails when caption too long [#10975](https://github.com/grafana/grafana/issues/10975) +- **Alerting**: Fixes silent failing validation [#11145](https://github.com/grafana/grafana/pull/11145) +- **OAuth**: Only use jwt token if it contains an email address [#11127](https://github.com/grafana/grafana/pull/11127) + +# 5.0.0-stable (2018-03-01) + +### Fixes + +- **oauth** Fix GitHub OAuth not working with private Organizations [#11028](https://github.com/grafana/grafana/pull/11028) [@lostick](https://github.com/lostick) +- **kiosk** white area over bottom panels in kiosk mode [#11010](https://github.com/grafana/grafana/issues/11010) +- **alerting** Fix OK state doesn't show up in Microsoft Teams [#11032](https://github.com/grafana/grafana/pull/11032), thx [@manacker](https://github.com/manacker) + +# 5.0.0-beta5 (2018-02-26) + +### Fixes + +- **Orgs** Unable to switch org when too many orgs listed [#10774](https://github.com/grafana/grafana/issues/10774) +- **Folders** Make it easier/explicit to access/modify folders using the API [#10630](https://github.com/grafana/grafana/issues/10630) +- **Dashboard** Scrollbar works incorrectly in Grafana 5.0 Beta4 in some cases [#10982](https://github.com/grafana/grafana/issues/10982) +- **ElasticSearch** Custom aggregation sizes no longer allowed for Elasticsearch [#10124](https://github.com/grafana/grafana/issues/10124) +- **oauth** GitHub OAuth with allowed organizations fails to login [#10964](https://github.com/grafana/grafana/issues/10964) +- **heatmap** Heatmap panel has partially hidden legend [#10793](https://github.com/grafana/grafana/issues/10793) +- **snapshots** Expired snapshots not being cleaned up [#10996](https://github.com/grafana/grafana/pull/10996) + +# 5.0.0-beta4 (2018-02-19) + +### Fixes + +- **Dashboard** Fixed dashboard overwrite permission issue [#10814](https://github.com/grafana/grafana/issues/10814) +- **Keyboard shortcuts** Fixed Esc key when in panel edit/view mode [#10945](https://github.com/grafana/grafana/issues/10945) +- **Save dashboard** Fixed issue with time range & variable reset after saving [#10946](https://github.com/grafana/grafana/issues/10946) + +# 5.0.0-beta3 (2018-02-16) + +### Fixes + +- **MySQL** Fixed new migration issue with index length [#10931](https://github.com/grafana/grafana/issues/10931) +- **Modal** Escape key no closes modals everywhere, fixes [#10887](https://github.com/grafana/grafana/issues/10887) +- **Row repeats** Fix for repeating rows issue, fixes [#10932](https://github.com/grafana/grafana/issues/10932) +- **Docs** Team api documented, fixes [#10832](https://github.com/grafana/grafana/issues/10832) +- **Plugins** Plugin info page broken, fixes [#10943](https://github.com/grafana/grafana/issues/10943) + +# 5.0.0-beta2 (2018-02-15) + +### Fixes + +- **Permissions** Fixed search permissions issues [#10822](https://github.com/grafana/grafana/issues/10822) +- **Permissions** Fixed problem issues displaying permissions lists [#10864](https://github.com/grafana/grafana/issues/10864) +- **PNG-Rendering** Fixed problem rendering legend to the right [#10526](https://github.com/grafana/grafana/issues/10526) +- **Reset password** Fixed problem with reset password form [#10870](https://github.com/grafana/grafana/issues/10870) +- **Light theme** Fixed problem with light theme in safari, [#10869](https://github.com/grafana/grafana/issues/10869) +- **Provisioning** Now handles deletes when dashboard json files removed from disk [#10865](https://github.com/grafana/grafana/issues/10865) +- **MySQL** Fixed issue with schema migration on old mysql (index too long) [#10779](https://github.com/grafana/grafana/issues/10779) +- **GitHub OAuth** Fixed fetching github orgs from private github org [#10823](https://github.com/grafana/grafana/issues/10823) +- **Embedding** Fixed issues embedding panel [#10787](https://github.com/grafana/grafana/issues/10787) + +# 5.0.0-beta1 (2018-02-05) + +Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=Izr0IBgoTZQ) of Grafana v5. + +### New Major Features + +- **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) +- **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. +- **Dashboard grid**: Panels are now laid out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). +- **Templating**: Vertical repeat direction for panel repeats. +- **UX**: Major update to page header and navigation +- **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) +- **Persistent dashboard url's**: New url's for dashboards that allows renaming dashboards without breaking links. [#7883](https://github.com/grafana/grafana/issues/7883) + +## Breaking changes + +- **[dashboard.json]** have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). + Config files for provisioning data sources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. + From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. + +- **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) + +- **HTTP API** + - `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url). + +## New Dashboard Grid + +The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. + +Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. + +## New Features + +- **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM) +- **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) +- **Postgres/MySQL**: add \_\_timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm) +- **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda) +- **Teams**: Add Microsoft Teams notifier as [#8523](https://github.com/grafana/grafana/issues/8523), thx [@anthu](https://github.com/anthu) +- **Data Sources**: Its now possible to configure data sources with config files [#1789](https://github.com/grafana/grafana/issues/1789) +- **Graphite**: Query editor updated to support new query by tag features [#9230](https://github.com/grafana/grafana/issues/9230) +- **Dashboard history**: New config file option versions_to_keep sets how many versions per dashboard to store, [#9671](https://github.com/grafana/grafana/issues/9671) +- **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) +- **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) +- **Table**: Support multiple table formatted queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) +- **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616) + +## Minor + +- **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) +- **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) +- **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend) +- **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell) +- **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda) +- **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871) +- **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798) +- **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler) +- **GitHub**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx + [@adiletmaratov](https://github.com/adiletmaratov) +- **Backend**: Fixed bug where Grafana exited before all sub routines where finished [#10131](https://github.com/grafana/grafana/issues/10131) +- **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada) +- **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell) + +## Fixes + +- **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) +- **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) +- **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) +- **Pagerduty**: Pagerduty don't auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +- **Cloudwatch**: Fix for multi-valued templated queries. [#9903](https://github.com/grafana/grafana/issues/9903) + +## Tech + +- **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) + +## Deprecation notes + +### HTTP API + +The following operations have been deprecated and will be removed in a future release: + +- `GET /api/dashboards/db/:slug` -> Use `GET /api/dashboards/uid/:uid` instead +- `DELETE /api/dashboards/db/:slug` -> Use `DELETE /api/dashboards/uid/:uid` instead + +The following properties have been deprecated and will be removed in a future release: + +- `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead +- `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead + +# 4.6.4 (2018-08-29) + +### Important fix for LDAP & OAuth login vulnerability + +See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4-6-4-security-update/10050) for details. + +# 4.6.3 (2017-12-14) + +## Fixes + +- **Gzip**: Fixes bug gravatar images when gzip was enabled [#5952](https://github.com/grafana/grafana/issues/5952) +- **Alert list**: Now shows alert state changes even after adding manual annotations on dashboard [#9951](https://github.com/grafana/grafana/issues/9951) +- **Alerting**: Fixes bug where rules evaluated as firing when all conditions was false and using OR operator. [#9318](https://github.com/grafana/grafana/issues/9318) +- **Cloudwatch**: CloudWatch no longer display metrics' default alias [#10151](https://github.com/grafana/grafana/issues/10151), thx [@mtanda](https://github.com/mtanda) + +# 4.6.2 (2017-11-16) + +## Important + +- **Prometheus**: Fixes bug with new prometheus alerts in Grafana. Make sure to download this version if you're using Prometheus for alerting. More details in the issue. [#9777](https://github.com/grafana/grafana/issues/9777) + +## Fixes + +- **Color picker**: Bug after using textbox input field to change/paste color string [#9769](https://github.com/grafana/grafana/issues/9769) +- **Cloudwatch**: Fix for cloudwatch templating query `ec2_instance_attribute` [#9667](https://github.com/grafana/grafana/issues/9667), thanks [@mtanda](https://github.com/mtanda) +- **Heatmap**: Fixed tooltip for "time series buckets" mode [#9332](https://github.com/grafana/grafana/issues/9332) +- **InfluxDB**: Fixed query editor issue when using `>` or `<` operators in WHERE clause [#9871](https://github.com/grafana/grafana/issues/9871) + +# 4.6.1 (2017-11-01) + +- **Singlestat**: Lost thresholds when using save dashboard as [#9681](https://github.com/grafana/grafana/issues/9681) +- **Graph**: Fix for series override color picker [#9715](https://github.com/grafana/grafana/issues/9715) +- **Go**: build using golang 1.9.2 [#9713](https://github.com/grafana/grafana/issues/9713) +- **Plugins**: Fixed problem with loading plugin js files behind auth proxy [#9509](https://github.com/grafana/grafana/issues/9509) +- **Graphite**: Annotation tooltip should render empty string when undefined [#9707](https://github.com/grafana/grafana/issues/9707) + +# 4.6.0 (2017-10-26) + +## Fixes + +- **Alerting**: Viewer can no longer pause alert rules [#9640](https://github.com/grafana/grafana/issues/9640) +- **Playlist**: Bug where playlist controls was missing [#9639](https://github.com/grafana/grafana/issues/9639) +- **Firefox**: Creating region annotations now work in firefox [#9638](https://github.com/grafana/grafana/issues/9638) + +# 4.6.0-beta3 (2017-10-23) + +## Fixes + +- **Prometheus**: Fix for browser crash for short time ranges. [#9575](https://github.com/grafana/grafana/issues/9575) +- **Heatmap**: Fix for y-axis not showing. [#9576](https://github.com/grafana/grafana/issues/9576) +- **Save to file**: Fix for save to file in export modal. [#9586](https://github.com/grafana/grafana/issues/9586) +- **Postgres**: modify group by time macro so it can be used in select clause [#9527](https://github.com/grafana/grafana/pull/9527), thanks [@svenklemm](https://github.com/svenklemm) + +# 4.6.0-beta2 (2017-10-17) + +## Fixes + +- **ColorPicker**: Fix for color picker not showing [#9549](https://github.com/grafana/grafana/issues/9549) +- **Alerting**: Fix for broken test rule button in alert tab [#9539](https://github.com/grafana/grafana/issues/9539) +- **Cloudwatch**: Provide error message when failing to add cloudwatch data source [#9534](https://github.com/grafana/grafana/pull/9534), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Fix unused period parameter [#9536](https://github.com/grafana/grafana/pull/9536), thx [@mtanda](https://github.com/mtanda) +- **CSV Export**: Fix for broken CSV export [#9525](https://github.com/grafana/grafana/issues/9525) +- **Text panel**: Fix for issue with break lines in Firefox [#9491](https://github.com/grafana/grafana/issues/9491) +- **Annotations**: Fix for issue saving annotation event in MySQL DB [#9550](https://github.com/grafana/grafana/issues/9550), thanks [@krise3k](https://github.com/krise3k) + +# 4.6.0-beta1 (2017-10-13) + +## New Features + +- **Annotations**: Add support for creating annotations from graph panel [#8197](https://github.com/grafana/grafana/pull/8197) +- **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) +- **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) +- **Graph**: Add support for local formatting in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +- **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) +- **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +- **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) +- **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add support for alerting using the cloudwatch data source [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +- **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +- **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +- **Prometheus**: Align \$\_\_interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +- **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +- **Data sources**: Make data source HTTP requests verify TLS by default. closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) +- **OAuth**: Verify TLS during OAuth callback [#9373](https://github.com/grafana/grafana/issues/9373), thx [@mattbostock](https://github.com/mattbostock) + +## Minor + +- **SMTP**: Make it possible to set specific HELO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319) +- **Dataproxy**: Allow grafana to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250) +- **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367) +- **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +- **Slack**: Allow images to be uploaded to slack when Token is present [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +- **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +- **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +- **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) +- **Kafka**: Add support for sending alert notifications to kafka [#7104](https://github.com/grafana/grafana/issues/7104), thx [@utkarshcmu](https://github.com/utkarshcmu) +- **Alerting**: add count_non_null as series reducer [#9516](https://github.com/grafana/grafana/issues/9516) + +## Tech + +- **Go**: Grafana is now built using golang 1.9 +- **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) for more details on how this can effect some plugins. + +# 4.5.2 (2017-09-22) + +## Fixes + +- **Graphite**: Fix for issues with jsonData & graphiteVersion null errors [#9258](https://github.com/grafana/grafana/issues/9258) +- **Graphite**: Fix for Grafana internal metrics to Graphite sending NaN values [#9279](https://github.com/grafana/grafana/issues/9279) +- **HTTP API**: Fix for HEAD method requests [#9307](https://github.com/grafana/grafana/issues/9307) +- **Templating**: Fix for duplicate template variable queries when refresh is set to time range change [#9185](https://github.com/grafana/grafana/issues/9185) +- **Metrics**: don't write NaN values to graphite [#9279](https://github.com/grafana/grafana/issues/9279) + +# 4.5.1 (2017-09-15) + +## Fixes + +- **MySQL**: Fixed issue with query editor not showing [#9247](https://github.com/grafana/grafana/issues/9247) + +## Breaking changes + +- **Metrics**: The metric structure for internal metrics about Grafana published to graphite has changed. This might break dashboards for internal metrics. + +# 4.5.0 (2017-09-14) + +## Fixes & Enhancements since beta1 + +- **Security**: Security fix for api vulnerability (in multiple org setups). +- **Shortcuts**: Adds shortcut for creating new dashboard [#8876](https://github.com/grafana/grafana/pull/8876) thx [@mtanda](https://github.com/mtanda) +- **Graph**: Right Y-Axis label position fixed [#9172](https://github.com/grafana/grafana/pull/9172) +- **General**: Improve rounding of time intervals [#9197](https://github.com/grafana/grafana/pull/9197), thx [@alin-amana](https://github.com/alin-amana) + +# 4.5.0-beta1 (2017-09-05) + +## New Features + +- **Table panel**: Render cell values as links that can have an url template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +- **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052). +- **MySQL**: New rich query editor with syntax highlighting +- **Prometheus**: New rich query editor with syntax highlighting, metric & range auto complete and integrated function docs. [#5117](https://github.com/grafana/grafana/issues/5117) + +## Enhancements + +- **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) +- **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboard time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) +- **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) + +- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). + +### Breaking change + +- **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. +- **Elasticsearch**: Elasticsearch metric queries without date histogram now return table formatted data making table panel much easier to use for this use case. Should not break/change existing dashboards with stock panels but external panel plugins can be affected. + +## Changes + +- **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) +- **InfluxDB**: Added parenthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) + +## Bug Fixes + +- **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800) +- **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510) +- **MySQL/Postgres**: Fix for max_idle_conn option default which was wrongly set to zero which does not mean unlimited but means zero, which in practice kind of disables connection pooling, which is not good. Fixes [#8513](https://github.com/grafana/grafana/issues/8513) + +# 4.4.3 (2017-08-07) + +## Bug Fixes + +- **Search**: Fix for issue that caused search view to hide when you clicked starred or tags filters, fixes [#8981](https://github.com/grafana/grafana/issues/8981) +- **Modals**: ESC key now closes modal again, fixes [#8981](https://github.com/grafana/grafana/issues/8988), thx [@j-white](https://github.com/j-white) + +# 4.4.2 (2017-08-01) + +## Bug Fixes + +- **GrafanaDB(mysql)**: Fix for dashboard_version.data column type, now changed to MEDIUMTEXT, fixes [#8813](https://github.com/grafana/grafana/issues/8813) +- **Dashboard(settings)**: Closing setting views using ESC key did not update url correctly, fixes [#8869](https://github.com/grafana/grafana/issues/8869) +- **InfluxDB**: Wrong username/password parameter name when using direct access, fixes [#8789](https://github.com/grafana/grafana/issues/8789) +- **Forms(TextArea)**: Bug fix for no scroll in text areas [#8797](https://github.com/grafana/grafana/issues/8797) +- **Png Render API**: Bug fix for timeout url parameter. It now works as it should. Default value was also increased from 30 to 60 seconds [#8710](https://github.com/grafana/grafana/issues/8710) +- **Search**: Fix for not being able to close search by clicking on right side of search result container, [8848](https://github.com/grafana/grafana/issues/8848) +- **Cloudwatch**: Fix for using variables in templating metrics() query, [8965](https://github.com/grafana/grafana/issues/8965) + +## Changes + +- **Settings(defaults)**: allow_sign_up default changed from true to false [#8743](https://github.com/grafana/grafana/issues/8743) +- **Settings(defaults)**: allow_org_create default changed from true to false + +# 4.4.1 (2017-07-05) + +## Bug Fixes + +- **Migrations**: migration fails where dashboard.created_by is null [#8783](https://github.com/grafana/grafana/issues/8783) + +# 4.4.0 (2017-07-04) + +## New Features + +**Dashboard History**: View dashboard version history, compare any two versions (summary & json diffs), restore to old version. This big feature +was contributed by **Walmart Labs**. Big thanks to them for this massive contribution! +Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638) +Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472) + +## Enhancements + +- **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk) +- **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller) +- **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo) +- **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617) +- **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang) + +## Minor Enhancements + +- **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia) + +## Bug Fixes + +- **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628) + +# 4.3.2 (2017-05-31) + +## Bug fixes + +- **InfluxDB**: Fixed issue with query editor not showing ALIAS BY input field when in text editor mode [#8459](https://github.com/grafana/grafana/issues/8459) +- **Graph Log Scale**: Fixed issue with log scale going below x-axis [#8244](https://github.com/grafana/grafana/issues/8244) +- **Playlist**: Fixed dashboard play order issue [#7688](https://github.com/grafana/grafana/issues/7688) +- **Elasticsearch**: Fixed table query issue with ES 2.x [#8467](https://github.com/grafana/grafana/issues/8467), thx [@goldeelox](https://github.com/goldeelox) + +## Changes + +- **Lazy Loading Of Panels**: Panels are no longer loaded as they are scrolled into view, this was reverted due to Chrome bug, might be reintroduced when Chrome fixes it's JS blocking behavior on scroll. [#8500](https://github.com/grafana/grafana/issues/8500) + +# 4.3.1 (2017-05-23) + +## Bug fixes + +- **S3 image upload**: Fixed image url issue for us-east-1 (us standard) region. If you were missing slack images for alert notifications this should fix it. [#8444](https://github.com/grafana/grafana/issues/8444) + +# 4.3.0-stable (2017-05-23) + +## Bug fixes + +- **Gzip**: Fixed crash when gzip was enabled [#8380](https://github.com/grafana/grafana/issues/8380) +- **Graphite**: Fixed issue with Toggle edit mode did in query editor [#8377](https://github.com/grafana/grafana/issues/8377) +- **Alerting**: Fixed issue with state history not showing query execution errors [#8412](https://github.com/grafana/grafana/issues/8412) +- **Alerting**: Fixed issue with missing state history events/annotations when using sqlite3 database [#7992](https://github.com/grafana/grafana/issues/7992) +- **Sqlite**: Fixed with database table locked and using sqlite3 database [#7992](https://github.com/grafana/grafana/issues/7992) +- **Alerting**: Fixed issue with annotations showing up in unsaved dashboards, new graph & alert panel. [#8361](https://github.com/grafana/grafana/issues/8361) +- **webdav**: Fixed http proxy env variable support for webdav image upload [#7922](https://github.com/grafana/grafana/issues/79222), thx [@berghauz](https://github.com/berghauz) +- **Prometheus**: Fixed issue with hiding query [#8413](https://github.com/grafana/grafana/issues/8413) + +## Enhancements + +- **VictorOps**: Now supports panel image & auto resolve [#8431](https://github.com/grafana/grafana/pull/8431), thx [@davidmscott](https://github.com/davidmscott) +- **Alerting**: Alert annotations now provide more info [#8421](https://github.com/grafana/grafana/pull/8421) + +# 4.3.0-beta1 (2017-05-12) + +## Enhancements + +- **InfluxDB**: influxdb query builder support for ORDER BY and LIMIT (allows TOPN queries) [#6065](https://github.com/grafana/grafana/issues/6065) Support influxdb's SLIMIT Feature [#7232](https://github.com/grafana/grafana/issues/7232) thx [@thuck](https://github.com/thuck) +- **Panels**: Delay loading & Lazy load panels as they become visible (scrolled into view) [#5216](https://github.com/grafana/grafana/issues/5216) thx [@jifwin](https://github.com/jifwin) +- **Graph**: Support auto grid min/max when using log scale [#3090](https://github.com/grafana/grafana/issues/3090), thx [@bigbenhur](https://github.com/bigbenhur) +- **Graph**: Support for histograms [#600](https://github.com/grafana/grafana/issues/600) +- **Prometheus**: Support table response formats (column per label) [#6140](https://github.com/grafana/grafana/issues/6140), thx [@mtanda](https://github.com/mtanda) +- **Single Stat Panel**: support for non time series data [#6564](https://github.com/grafana/grafana/issues/6564) +- **Server**: Monitoring Grafana (health check endpoint) [#3302](https://github.com/grafana/grafana/issues/3302) +- **Heatmap**: Heatmap Panel [#7934](https://github.com/grafana/grafana/pull/7934) +- **Elasticsearch**: histogram aggregation [#3164](https://github.com/grafana/grafana/issues/3164) + +## Minor Enhancements + +- **InfluxDB**: Small fix for the "glow" when focus the field for LIMIT and SLIMIT [#7799](https://github.com/grafana/grafana/pull/7799) thx [@thuck](https://github.com/thuck) +- **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778) +- **Prometheus**: Step parameter changed semantics to min step to reduce the load on Prometheus and rendering in browser [#8073](https://github.com/grafana/grafana/pull/8073), thx [@bobrik](https://github.com/bobrik) +- **Templating**: Should not be possible to create self-referencing (recursive) template variable definitions [#7614](https://github.com/grafana/grafana/issues/7614) thx [@thuck](https://github.com/thuck) +- **Cloudwatch**: Correctly obtain IAM roles within ECS container tasks [#7892](https://github.com/grafana/grafana/issues/7892) thx [@gomlgs](https://github.com/gomlgs) +- **Units**: New number format: Scientific notation [#7781](https://github.com/grafana/grafana/issues/7781) thx [@cadnce](https://github.com/cadnce) +- **Oauth**: Add common type for oauth authorization errors [#6428](https://github.com/grafana/grafana/issues/6428) thx [@amenzhinsky](https://github.com/amenzhinsky) +- **Templating**: Data source variable now supports multi value and panel repeats [#7030](https://github.com/grafana/grafana/issues/7030) thx [@mtanda](https://github.com/mtanda) +- **Telegram**: Telegram alert is not sending metric and legend. [#8110](https://github.com/grafana/grafana/issues/8110), thx [@bashgeek](https://github.com/bashgeek) +- **Graph**: Support dashed lines [#514](https://github.com/grafana/grafana/issues/514), thx [@smalik03](https://github.com/smalik03) +- **Table**: Support to change column header text [#3551](https://github.com/grafana/grafana/issues/3551) +- **Alerting**: Better error when SMTP is not configured [#8093](https://github.com/grafana/grafana/issues/8093) +- **Pushover**: Add an option to attach graph image link in Pushover notification [#8043](https://github.com/grafana/grafana/issues/8043) thx [@devkid](https://github.com/devkid) +- **WebDAV**: Allow to set different ImageBaseUrl for WebDAV upload and image link [#7914](https://github.com/grafana/grafana/issues/7914) +- **Panels**: type-ahead mixed data source selection [#7697](https://github.com/grafana/grafana/issues/7697) thx [@mtanda](https://github.com/mtanda) +- **Security**:User enumeration problem [#7619](https://github.com/grafana/grafana/issues/7619) +- **InfluxDB**: Register new queries available in InfluxDB - Holt Winters [#5619](https://github.com/grafana/grafana/issues/5619) thx [@rikkuness](https://github.com/rikkuness) +- **Server**: Support listening on a UNIX socket [#4030](https://github.com/grafana/grafana/issues/4030), thx [@mitjaziv](https://github.com/mitjaziv) +- **Graph**: Support log scaling for values smaller 1 [#5278](https://github.com/grafana/grafana/issues/5278) +- **InfluxDB**: Slow 'select measurement' rendering for InfluxDB [#2524](https://github.com/grafana/grafana/issues/2524), thx [@sbhenderson](https://github.com/sbhenderson) +- **Config**: Configurable signout menu activation [#7968](https://github.com/grafana/grafana/pull/7968), thx [@seuf](https://github.com/seuf) + +## Fixes + +- **Table Panel**: Fixed annotation display in table panel, [#8023](https://github.com/grafana/grafana/issues/8023) +- **Dashboard**: If refresh is blocked due to tab not visible, then refresh when it becomes visible [#8076](https://github.com/grafana/grafana/issues/8076) thanks [@SimenB](https://github.com/SimenB) +- **Snapshots**: Fixed problem with annotations & snapshots [#7659](https://github.com/grafana/grafana/issues/7659) +- **Graph**: MetricSegment loses type when value is an asterisk [#8277](https://github.com/grafana/grafana/issues/8277), thx [@Gordiychuk](https://github.com/Gordiychuk) +- **Alerting**: Alert notifications do not show charts when using a non public S3 bucket [#8250](https://github.com/grafana/grafana/issues/8250) thx [@rogerswingle](https://github.com/rogerswingle) +- **Graph**: 100% client CPU usage on red alert glow animation [#8222](https://github.com/grafana/grafana/issues/8222) +- **InfluxDB**: Templating: "All" query does match too much [#8165](https://github.com/grafana/grafana/issues/8165) +- **Dashboard**: Description tooltip is not fully displayed [#7970](https://github.com/grafana/grafana/issues/7970) +- **Proxy**: Redirect after switching Org does not obey sub path in root_url (using reverse proxy) [#8089](https://github.com/grafana/grafana/issues/8089) +- **Templating**: Restoration of ad-hoc variable from URL does not work correctly [#8056](https://github.com/grafana/grafana/issues/8056) thx [@tamayika](https://github.com/tamayika) +- **InfluxDB**: timeFilter cannot be used twice in alerts [#7969](https://github.com/grafana/grafana/issues/7969) +- **MySQL**: 4-byte UTF8 not supported when using MySQL database (allows Emojis) [#7958](https://github.com/grafana/grafana/issues/7958) +- **Alerting**: api/alerts and api/alert/:id hold previous data for "message" and "Message" field when field value is changed from "some string" to empty string. [#7927](https://github.com/grafana/grafana/issues/7927) +- **Graph**: Cannot add fill below to series override [#7916](https://github.com/grafana/grafana/issues/7916) +- **InfluxDB**: Influxb Data source test passes even if the Database doesn't exist [#7864](https://github.com/grafana/grafana/issues/7864) +- **Prometheus**: Displaying Prometheus annotations is incredibly slow [#7750](https://github.com/grafana/grafana/issues/7750), thx [@mtanda](https://github.com/mtanda) +- **Graphite**: grafana generates empty find query to graphite -> 422 Unprocessable Entity [#7740](https://github.com/grafana/grafana/issues/7740) +- **Admin**: make organization filter case insensitive [#8194](https://github.com/grafana/grafana/issues/8194), thx [@Alexander-N](https://github.com/Alexander-N) + +## Changes + +- **Elasticsearch**: Changed elasticsearch Terms aggregation to default to Min Doc Count to 1, and sort order to Top [#8321](https://github.com/grafana/grafana/issues/8321) + +## Tech + +- **Library Upgrade**: inconshreveable/log15 outdated - no support for solaris [#8262](https://github.com/grafana/grafana/issues/8262) +- **Library Upgrade**: Upgrade Macaron [#7600](https://github.com/grafana/grafana/issues/7600) + +# 4.2.0 (2017-03-22) + +## Minor Enhancements + +- **Templates**: Prevent use of the prefix `__` for templates in web UI [#7678](https://github.com/grafana/grafana/issues/7678) +- **Threema**: Add emoji to Threema alert notifications [#7676](https://github.com/grafana/grafana/pull/7676) thx [@dbrgn](https://github.com/dbrgn) +- **Panels**: Support dm3 unit [#7695](https://github.com/grafana/grafana/issues/7695) thx [@mitjaziv](https://github.com/mitjaziv) +- **Docs**: Added some details about Sessions in Postgres [#7694](https://github.com/grafana/grafana/pull/7694) thx [@rickard-von-essen](https://github.com/rickard-von-essen) +- **Influxdb**: Allow commas in template variables [#7681](https://github.com/grafana/grafana/issues/7681) thx [@thuck](https://github.com/thuck) +- **Cloudwatch**: stop using deprecated session.New() [#7736](https://github.com/grafana/grafana/issues/7736) thx [@mtanda](https://github.com/mtanda) + \*TSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges**: Pass dropcounter rate option if no max counter and no reset value or reset value as 0 is specified [#7743](https://github.com/grafana/grafana/pull/7743) thx [@r4um](https://github.com/r4um) +- **Templating**: support full resolution for \$interval variable [#7696](https://github.com/grafana/grafana/pull/7696) thx [@mtanda](https://github.com/mtanda) +- **Elasticsearch**: Unique Count on string fields in ElasticSearch [#3536](https://github.com/grafana/grafana/issues/3536), thx [@pyro2927](https://github.com/pyro2927) +- **Templating**: Data source template variable that refers to other variable in regex filter [#6365](https://github.com/grafana/grafana/issues/6365) thx [@rlodge](https://github.com/rlodge) +- **Admin**: Global User List: add search and pagination [#7469](https://github.com/grafana/grafana/issues/7469) +- **User Management**: Invite UI is now disabled when login form is disabled [#7875](https://github.com/grafana/grafana/issues/7875) + +## Bugfixes + +- **Webhook**: Use proxy settings from environment variables [#7710](https://github.com/grafana/grafana/issues/7710) +- **Panels**: Deleting a dashboard with unsaved changes raises an error message [#7591](https://github.com/grafana/grafana/issues/7591) thx [@thuck](https://github.com/thuck) +- **Influxdb**: Query builder detects regex to easily for measurement [#7276](https://github.com/grafana/grafana/issues/7276) thx [@thuck](https://github.com/thuck) +- **Docs**: router_logging not documented [#7723](https://github.com/grafana/grafana/issues/7723) +- **Alerting**: Spelling mistake [#7739](https://github.com/grafana/grafana/pull/7739) thx [@woutersmit](https://github.com/woutersmit) +- **Alerting**: Graph legend scrolls to top when an alias is toggled/clicked [#7680](https://github.com/grafana/grafana/issues/7680) thx [@p4ddy1](https://github.com/p4ddy1) +- **Panels**: Fixed panel tooltip description after scrolling down [#7708](https://github.com/grafana/grafana/issues/7708) thx [@askomorokhov](https://github.com/askomorokhov) + +# 4.2.0-beta1 (2017-02-27) + +## Enhancements + +- **Telegram**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) +- **Templating**: Make $__interval and $\_\_interval_ms global built in variables that can be used in by any data source (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) +- **S3 Image Store**: External s3 image store (used in alert notifications) now support AWS IAM Roles, closes [#6985](https://github.com/grafana/grafana/issues/6985), [#7058](https://github.com/grafana/grafana/issues/7058) thx [@mtanda](https://github.com/mtanda) +- **SingleStat**: Implements diff aggregation method for singlestat [#7234](https://github.com/grafana/grafana/issues/7234), thx [@oliverpool](https://github.com/oliverpool) +- **Dataproxy**: Added setting to enable more verbose logging in dataproxy [#7209](https://github.com/grafana/grafana/pull/7209), thx [@Ricky-N](https://github.com/Ricky-N) +- **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) +- **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [@huydx](https://github.com/huydx) +- **LINE**: Adds image to notification message [#7417](https://github.com/grafana/grafana/pull/7417), thx [@Erliz](https://github.com/Erliz) +- **Hipchat**: Adds support for sending alert notifications to hipchat [#6451](https://github.com/grafana/grafana/issues/6451), thx [@jregovic](https://github.com/jregovic) +- **Alerting**: Uploading images for alert notifications is now optional [#7419](https://github.com/grafana/grafana/issues/7419) +- **Dashboard**: Adds shortcut for collapsing/expanding all rows [#552](https://github.com/grafana/grafana/issues/552), thx [@mtanda](https://github.com/mtanda) +- **Alerting**: Adds de duping of alert notifications [#7632](https://github.com/grafana/grafana/pull/7632) +- **Orgs**: Sharing dashboards using Grafana share feature will now redirect to correct org. [#1613](https://github.com/grafana/grafana/issues/1613) +- **Pushover**: Add Pushover alert notifications [#7526](https://github.com/grafana/grafana/pull/7526) thx [@devkid](https://github.com/devkid) +- **Threema**: Add Threema Gateway alert notification integration [#7482](https://github.com/grafana/grafana/pull/7482) thx [@dbrgn](https://github.com/dbrgn) + +## Minor Enhancements + +- **Optimization**: Never issue refresh event when Grafana tab is not visible [#7218](https://github.com/grafana/grafana/issues/7218), thx [@mtanda](https://github.com/mtanda) +- **Browser History**: Browser back/forward now works time ranges / zoom, [#7259](https://github.com/grafana/grafana/issues/7259) +- **Elasticsearch**: Support for Min Doc Count options in Terms aggregation [#7324](https://github.com/grafana/grafana/pull/7324), thx [@lpic10](https://github.com/lpic10) +- **Elasticsearch**: Term aggregation limit can now be changed in template queries [#7112](https://github.com/grafana/grafana/issues/7112), thx [@FFalcon](https://github.com/FFalcon) +- **Elasticsearch**: Ad-hoc filters now support all operators [#7612](https://github.com/grafana/grafana/issues/7612), thx [@tamayika](https://github.com/tamayika) +- **Graph**: Add full series name as title for legends. [#7493](https://github.com/grafana/grafana/pull/7493), thx [@kolobaev](https://github.com/kolobaev) +- **Table**: Add a message when queries returns no data. [#6109](https://github.com/grafana/grafana/issues/6109), thx [@xginn8](https://github.com/xginn8) +- **Graph**: Set max width for series names in legend tables. [#2385](https://github.com/grafana/grafana/issues/2385), thx [@kolobaev](https://github.com/kolobaev) +- **Database**: Allow max db connection pool configuration [#7427](https://github.com/grafana/grafana/issues/7427), thx [@huydx](https://github.com/huydx) +- **Data Sources** Delete datsource by name [#7476](https://github.com/grafana/grafana/issues/7476), thx [@huydx](https://github.com/huydx) +- **Dataproxy**: Only allow get that begins with api/ to access Prometheus [#7459](https://github.com/grafana/grafana/pull/7459), thx [@mtanda](https://github.com/mtanda) +- **Snapshot**: Make timeout for snapshot creation configurable [#7449](https://github.com/grafana/grafana/pull/7449) thx [@ryu1-sakai](https://github.com/ryu1-sakai) +- **Panels**: Add more physics units [#7554](https://github.com/grafana/grafana/pull/7554) thx [@ryantxu](https://github.com/ryantxu) +- **Email**: Add sender's name on email [#2131](https://github.com/grafana/grafana/issues/2131) thx [@jacobbednarz](https://github.com/jacobbednarz) +- **HTTPS**: Set tls 1.2 as lowest tls version. [#7347](https://github.com/grafana/grafana/pull/7347) thx [@roman-vynar](https://github.com/roman-vynar) +- **Table**: Added suppressing of empty results to table plugin. [#7602](https://github.com/grafana/grafana/pull/7602) thx [@LLIyRiK](https://github.com/LLIyRiK) + +## Tech + +- **Library Upgrade**: Upgraded angularjs from 1.5.8 to 1.6.1 [#7274](https://github.com/grafana/grafana/issues/7274) +- **Backend**: Grafana is now built using golang 1.8 + +## Bugfixes + +- **Alerting**: Fixes missing support for no_data and execution error when testing alerts [#7149](https://github.com/grafana/grafana/issues/7149) +- **Dashboard**: Avoid duplicate data in dashboard json for panels with alerts [#7256](https://github.com/grafana/grafana/pull/7256) +- **Alertlist**: Only show scrollbar when required [#7269](https://github.com/grafana/grafana/issues/7269) +- **SMTP**: Set LocalName to hostname [#7223](https://github.com/grafana/grafana/issues/7223) +- **Sidemenu**: Disable sign out in sidemenu for AuthProxyEnabled [#7377](https://github.com/grafana/grafana/pull/7377), thx [@solugebefola](https://github.com/solugebefola) +- **Prometheus**: Add support for basic auth in Prometheus tsdb package [#6799](https://github.com/grafana/grafana/issues/6799), thx [@hagen1778](https://github.com/hagen1778) +- **OAuth**: Redirect to original page when logging in with OAuth [#7513](https://github.com/grafana/grafana/issues/7513) +- **Annotations**: Wrap text in annotations tooltip [#7542](https://github.com/grafana/grafana/pull/7542), thx [@xginn8](https://github.com/xginn8) +- **Templating**: Fixes error when using numeric sort on empty strings [#7382](https://github.com/grafana/grafana/issues/7382) +- **Templating**: Fixed issue detecting template variable dependency [#7354](https://github.com/grafana/grafana/issues/7354) + +# 4.1.2 (2017-02-13) + +### Bugfixes + +- **Table**: Fixes broken annotation rendering mode in the table panel [#7268](https://github.com/grafana/grafana/issues/7268) +- **Data Sources**: Sorting for lists of data sources in UI is now case insensitive [#7491](https://github.com/grafana/grafana/issues/7491) +- **Admin**: Support more then 1000 users in global users list [#7469](https://github.com/grafana/grafana/issues/7469) + +# 4.1.1 (2017-01-11) + +### Bugfixes + +- **Graph Panel**: Fixed issue with legend height in table mode [#7221](https://github.com/grafana/grafana/issues/7221) + +# 4.1.0 (2017-01-11) + +### Bugfixes + +- **Server side PNG rendering**: Fixed issue with y-axis label rotation in phantomjs rendered images [#6924](https://github.com/grafana/grafana/issues/6924) +- **Graph**: Fixed centering of y-axis label [#7099](https://github.com/grafana/grafana/issues/7099) +- **Graph**: Fixed graph legend table mode and always visible scrollbar [#6828](https://github.com/grafana/grafana/issues/6828) +- **Templating**: Fixed template variable value groups/tags feature [#6752](https://github.com/grafana/grafana/issues/6752) +- **Webhook**: Fixed webhook username mismatch [#7195](https://github.com/grafana/grafana/pull/7195), thx [@theisenmark](https://github.com/theisenmark) +- **Influxdb**: Handles time(auto) the same way as time(\$interval) [#6997](https://github.com/grafana/grafana/issues/6997) + +## Enhancements + +- **Elasticsearch**: Added support for all moving average options [#7154](https://github.com/grafana/grafana/pull/7154), thx [@vaibhavinbayarea](https://github.com/vaibhavinbayarea) + +# 4.1-beta1 (2016-12-21) + +### Enhancements + +- **Postgres**: Add support for Certs for Postgres database [#6655](https://github.com/grafana/grafana/issues/6655) +- **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin) +- **Opsgenie**: Add OpsGenie notification integration [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc) +- **Singlestat**: New aggregation on singlestat panel [#6740](https://github.com/grafana/grafana/pull/6740), thx [@dirk-leroux](https://github.com/dirk-leroux) +- **Cloudwatch**: Make it possible to specify access and secret key on the data source config page [#6697](https://github.com/grafana/grafana/issues/6697) +- **Table**: Added Hidden Column Style for Table Panel [#5677](https://github.com/grafana/grafana/pull/5677), thx [@bmundt](https://github.com/bmundt) +- **Graph**: Shared crosshair option renamed to shared tooltip, shows tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274) +- **Elasticsearch**: Added support for Missing option (bucket) for terms aggregation [#4244](https://github.com/grafana/grafana/pull/4244), thx [@shanielh](https://github.com/shanielh) +- **Elasticsearch**: Added support for Elasticsearch 5.x [#5740](https://github.com/grafana/grafana/issues/5740), thx [@lpic10](https://github.com/lpic10) +- **CLI**: Make it possible to reset the admin password using the grafana-cli. [#5479](https://github.com/grafana/grafana/issues/5479) +- **Influxdb**: Support multiple tags in InfluxDB annotations. [#4550](https://github.com/grafana/grafana/pull/4550), thx [@adrianlzt](https://github.com/adrianlzt) +- **LDAP**: Basic Auth now supports LDAP username and password, [#6940](https://github.com/grafana/grafana/pull/6940), thx [@utkarshcmu](https://github.com/utkarshcmu) +- **LDAP**: Now works with Auth Proxy, role and organization mapping & sync will regularly be performed. [#6895](https://github.com/grafana/grafana/pull/6895), thx [@Seuf](https://github.com/seuf) +- **Alerting**: Adds OK as no data option. [#6866](https://github.com/grafana/grafana/issues/6866) +- **Alert list**: Order alerts based on state. [#6676](https://github.com/grafana/grafana/issues/6676) +- **Alerting**: Add api endpoint for pausing all alerts. [#6589](https://github.com/grafana/grafana/issues/6589) +- **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) + +### Bugfixes + +- **API**: HTTP API for deleting org returning incorrect message for a non-existing org [#6679](https://github.com/grafana/grafana/issues/6679) +- **Dashboard**: Posting empty dashboard result in corrupted dashboard [#5443](https://github.com/grafana/grafana/issues/5443) +- **Logging**: Fixed logging level config issue [#6978](https://github.com/grafana/grafana/issues/6978) +- **Notifications**: Remove html escaping the email subject. [#6905](https://github.com/grafana/grafana/issues/6905) +- **Influxdb**: Fixes broken field dropdown when using template vars as measurement. [#6473](https://github.com/grafana/grafana/issues/6473) + +# 4.0.2 (2016-12-08) + +### Enhancements + +- **Playlist**: Add support for kiosk mode [#6727](https://github.com/grafana/grafana/issues/6727) + +### Bugfixes + +- **Alerting**: Add alert message to webhook notifications [#6807](https://github.com/grafana/grafana/issues/6807) +- **Alerting**: Fixes a bug where avg() reducer treated null as zero. [#6879](https://github.com/grafana/grafana/issues/6879) +- **PNG Rendering**: Fix for server side rendering when using non default http addr bind and domain setting [#6813](https://github.com/grafana/grafana/issues/6813) +- **PNG Rendering**: Fix for server side rendering when setting enforce_domain to true [#6769](https://github.com/grafana/grafana/issues/6769) +- **Webhooks**: Add content type json to outgoing webhooks [#6822](https://github.com/grafana/grafana/issues/6822) +- **Keyboard shortcut**: Fixed zoom out shortcut [#6837](https://github.com/grafana/grafana/issues/6837) +- **Webdav**: Adds basic auth headers to webdav uploader [#6779](https://github.com/grafana/grafana/issues/6779) + +# 4.0.1 (2016-12-02) + +> **Notice** +> 4.0.0 had serious connection pooling issue when using a data source in proxy access. This bug caused lots of resource issues +> due to too many connections/file handles on the data source backend. This problem is fixed in this release. + +### Bugfixes + +- **Metrics**: Fixes nil pointer dereference on my arm build [#6749](https://github.com/grafana/grafana/issues/6749) +- **Data proxy**: Fixes a tcp pooling issue in the data source reverse proxy [#6759](https://github.com/grafana/grafana/issues/6759) + +# 4.0-stable (2016-11-29) + +### Bugfixes + +- **Server-side rendering**: Fixed address used when rendering panel via phantomjs and using non default http_addr config [#6660](https://github.com/grafana/grafana/issues/6660) +- **Graph panel**: Fixed graph panel tooltip sort order issue [#6648](https://github.com/grafana/grafana/issues/6648) +- **Unsaved changes**: You now navigate to the intended page after saving in the unsaved changes dialog [#6675](https://github.com/grafana/grafana/issues/6675) +- **TLS Client Auth**: Support for TLS client authentication for data source proxies [#2316](https://github.com/grafana/grafana/issues/2316) +- **Alerts out of sync**: Saving dashboards with broken alerts causes sync problem[#6576](https://github.com/grafana/grafana/issues/6576) +- **Alerting**: Saving an alert with condition "HAS NO DATA" throws an error[#6701](https://github.com/grafana/grafana/issues/6701) +- **Config**: Improve error message when parsing broken config file [#6731](https://github.com/grafana/grafana/issues/6731) +- **Table**: Render empty dates as - instead of current date [#6728](https://github.com/grafana/grafana/issues/6728) + +# 4.0-beta2 (2016-11-21) + +### Bugfixes + +- **Graph Panel**: Log base scale on right Y-axis had no effect, max value calc was not applied, [#6534](https://github.com/grafana/grafana/issues/6534) +- **Graph Panel**: Bar width if bars was only used in series override, [#6528](https://github.com/grafana/grafana/issues/6528) +- **UI/Browser**: Fixed issue with page/view header gradient border not showing in Safari, [#6530](https://github.com/grafana/grafana/issues/6530) +- **Cloudwatch**: Fixed cloudwatch data source requesting to many datapoints, [#6544](https://github.com/grafana/grafana/issues/6544) +- **UX**: Panel Drop zone visible after duplicating panel, and when entering fullscreen/edit view, [#6598](https://github.com/grafana/grafana/issues/6598) +- **Templating**: Newly added variable was not visible directly only after dashboard reload, [#6622](https://github.com/grafana/grafana/issues/6622) + +### Enhancements + +- **Singlestat**: Support repeated template variables in prefix/postfix [#6595](https://github.com/grafana/grafana/issues/6595) +- **Templating**: Don't persist variable options with refresh option [#6586](https://github.com/grafana/grafana/issues/6586) +- **Alerting**: Add ability to have OR conditions (and mixing AND & OR) [#6579](https://github.com/grafana/grafana/issues/6579) +- **InfluxDB**: Fix for Ad-Hoc Filters variable & changing dashboards [#6821](https://github.com/grafana/grafana/issues/6821) + +# 4.0-beta1 (2016-11-09) + +### Enhancements + +- **Login**: Adds option to disable username/password logins, closes [#4674](https://github.com/grafana/grafana/issues/4674) +- **SingleStat**: Add seriesName as option in singlestat panel, closes [#4740](https://github.com/grafana/grafana/issues/4740) +- **Localization**: Week start day now dependent on browser locale setting, closes [#3003](https://github.com/grafana/grafana/issues/3003) +- **Templating**: Update panel repeats for variables that change on time refresh, closes [#5021](https://github.com/grafana/grafana/issues/5021) +- **Templating**: Add support for numeric and alphabetical sorting of variable values, closes [#2839](https://github.com/grafana/grafana/issues/2839) +- **Elasticsearch**: Support to set Precision Threshold for Unique Count metric, closes [#4689](https://github.com/grafana/grafana/issues/4689) +- **Navigation**: Add search to org switcher, closes [#2609](https://github.com/grafana/grafana/issues/2609) +- **Database**: Allow database config using one property, closes [#5456](https://github.com/grafana/grafana/pull/5456) +- **Graphite**: Add support for groupByNodes, closes [#5613](https://github.com/grafana/grafana/pull/5613) +- **Influxdb**: Add support for elapsed(), closes [#5827](https://github.com/grafana/grafana/pull/5827) +- **OpenTSDB**: Add support for explicitTags for OpenTSDB>=2.3, closes [#6360](https://github.com/grafana/grafana/pull/6361) +- **OAuth**: Add support for generic oauth, closes [#4718](https://github.com/grafana/grafana/pull/4718) +- **Cloudwatch**: Add support to expand multi select template variable, closes [#5003](https://github.com/grafana/grafana/pull/5003) +- **Background Tasks**: Now support automatic purging of old snapshots, closes [#4087](https://github.com/grafana/grafana/issues/4087) +- **Background Tasks**: Now support automatic purging of old rendered images, closes [#2172](https://github.com/grafana/grafana/issues/2172) +- **Dashboard**: After inactivity hide nav/row actions, fade to nice clean view, can be toggled with `d v`, also added kiosk mode, toggled via `d k` [#6476](https://github.com/grafana/grafana/issues/6476) +- **Dashboard**: Improved dashboard row menu & add panel UX [#6442](https://github.com/grafana/grafana/issues/6442) +- **Graph Panel**: Support for stacking null values [#2912](https://github.com/grafana/grafana/issues/2912), [#6287](https://github.com/grafana/grafana/issues/6287), thanks @benrubson! + +### Breaking changes + +- **SystemD**: Change systemd description, closes [#5971](https://github.com/grafana/grafana/pull/5971) +- **lodash upgrade**: Upgraded lodash from 2.4.2 to 4.15.0, this contains a number of breaking changes that could effect plugins. closes [#6021](https://github.com/grafana/grafana/pull/6021) + +### Bug fixes + +- **Table Panel**: Fixed problem when switching to Mixed data source in metrics tab, fixes [#5999](https://github.com/grafana/grafana/pull/5999) +- **Playlist**: Fixed problem with play order not matching order defined in playlist, fixes [#5467](https://github.com/grafana/grafana/pull/5467) +- **Graph panel**: Fixed problem with auto decimals on y axis when datamin=datamax, fixes [#6070](https://github.com/grafana/grafana/pull/6070) +- **Snapshot**: Can view embedded panels/png rendered panels in snapshots without login, fixes [#3769](https://github.com/grafana/grafana/pull/3769) +- **Elasticsearch**: Fix for query template variable when looking up terms without query, no longer relies on elasticsearch default field, fixes [#3887](https://github.com/grafana/grafana/pull/3887) +- **Elasticsearch**: Fix for displaying IP address used in terms aggregations, fixes [#4393](https://github.com/grafana/grafana/pull/4393) +- **PNG Rendering**: Fix for server side rendering when using auth proxy, fixes [#5906](https://github.com/grafana/grafana/pull/5906) +- **OpenTSDB**: Fixed multi-value nested templating for opentsdb, fixes [#6455](https://github.com/grafana/grafana/pull/6455) +- **Playlist**: Remove playlist items when dashboard is removed, fixes [#6292](https://github.com/grafana/grafana/issues/6292) + +# 3.1.2 (unreleased) + +- **Templating**: Fixed issue when combining row & panel repeats, fixes [#5790](https://github.com/grafana/grafana/issues/5790) +- **Drag&Drop**: Fixed issue with drag and drop in latest Chrome(51+), fixes [#5767](https://github.com/grafana/grafana/issues/5767) +- **Internal Metrics**: Fixed issue with dots in instance_name when sending internal metrics to Graphite, fixes [#5739](https://github.com/grafana/grafana/issues/5739) +- **Grafana-CLI**: Add default plugin path for MAC OS, fixes [#5806](https://github.com/grafana/grafana/issues/5806) +- **Grafana-CLI**: Improve error message for upgrade-all command, fixes [#5885](https://github.com/grafana/grafana/issues/5885) + +# 3.1.1 (2016-08-01) + +- **IFrame embedding**: Fixed issue of using full iframe height, fixes [#5605](https://github.com/grafana/grafana/issues/5606) +- **Panel PNG rendering**: Fixed issue detecting render completion, fixes [#5605](https://github.com/grafana/grafana/issues/5606) +- **Elasticsearch**: Fixed issue with templating query and json parse error, fixes [#5615](https://github.com/grafana/grafana/issues/5615) +- **Tech**: Upgraded JQuery to 2.2.4 to fix Security vulnerabilities in 2.1.4, fixes [#5627](https://github.com/grafana/grafana/issues/5627) +- **Graphite**: Fixed issue with mixed data sources and Graphite, fixes [#5617](https://github.com/grafana/grafana/issues/5617) +- **Templating**: Fixed issue with template variable query was issued multiple times during dashboard load, fixes [#5637](https://github.com/grafana/grafana/issues/5637) +- **Zoom**: Fixed issues with zoom in and out on embedded (iframed) panel, fixes [#4489](https://github.com/grafana/grafana/issues/4489), [#5666](https://github.com/grafana/grafana/issues/5666) + +# 3.1.0 stable (2016-07-12) + +### Bugfixes & Enhancements, + +- **User Alert Notices**: Backend error alert popups did not show properly, fixes [#5435](https://github.com/grafana/grafana/issues/5435) +- **Table**: Added sanitize HTML option to allow links in table cells, fixes [#4596](https://github.com/grafana/grafana/issues/4596) +- **Apps**: App dashboards are automatically synced to DB at startup after plugin update, fixes [#5529](https://github.com/grafana/grafana/issues/5529) + +# 3.1.0-beta1 (2016-06-23) + +### Enhancements + +- **Dashboard Export/Import**: Dashboard export now templatize data sources and constant variables, users pick these on import, closes [#5084](https://github.com/grafana/grafana/issues/5084) +- **Dashboard Url**: Time range changes updates url, closes [#458](https://github.com/grafana/grafana/issues/458) +- **Dashboard Url**: Template variable change updates url, closes [#5002](https://github.com/grafana/grafana/issues/5002) +- **Singlestat**: Add support for range to text mappings, closes [#1319](https://github.com/grafana/grafana/issues/1319) +- **Graph**: Adds sort order options for graph tooltip, closes [#1189](https://github.com/grafana/grafana/issues/1189) +- **Theme**: Add default theme to config file [#5011](https://github.com/grafana/grafana/pull/5011) +- **Page Footer**: Added page footer with links to docs, shows Grafana version and info if new version is available, closes [#4889](https://github.com/grafana/grafana/pull/4889) +- **InfluxDB**: Add spread function, closes [#5211](https://github.com/grafana/grafana/issues/5211) +- **Scripts**: Use restart instead of start for deb package script, closes [#5282](https://github.com/grafana/grafana/pull/5282) +- **Logging**: Moved to structured logging lib, and moved to component specific level filters via config file, closes [#4590](https://github.com/grafana/grafana/issues/4590) +- **OpenTSDB**: Support nested template variables in tag_values function, closes [#4398](https://github.com/grafana/grafana/issues/4398) +- **Data Source**: Pending data source requests are canceled before new ones are issues (Graphite & Prometheus), closes [#5321](https://github.com/grafana/grafana/issues/5321) + +### Breaking changes + +- **Logging** : Changed default logging output format (now structured into message, and key value pairs, with logger key acting as component). You can also no change in config to json log output. +- **Graphite** : The Graph panel no longer have a Graphite PNG option. closes [#5367](https://github.com/grafana/grafana/issues/5367) + +### Bug fixes + +- **PNG rendering**: Fixed phantomjs rendering and y-axis label rotation. fixes [#5220](https://github.com/grafana/grafana/issues/5220) +- **CLI**: The cli tool now supports reading plugin.json from dist/plugin.json. fixes [#5410](https://github.com/grafana/grafana/issues/5410) + +# 3.0.4 Patch release (2016-05-25) + +- **Panel**: Fixed blank dashboard issue when switching to other dashboard while in fullscreen edit mode, fixes [#5163](https://github.com/grafana/grafana/pull/5163) +- **Templating**: Fixed issue with nested multi select variables and cascading and updating child variable selection state, fixes [#4861](https://github.com/grafana/grafana/pull/4861) +- **Templating**: Fixed issue with using templated data source in another template variable query, fixes [#5165](https://github.com/grafana/grafana/pull/5165) +- **Singlestat gauge**: Fixed issue with gauge render position, fixes [#5143](https://github.com/grafana/grafana/pull/5143) +- **Home dashboard**: Fixes broken home dashboard api, fixes [#5167](https://github.com/grafana/grafana/issues/5167) + +# 3.0.3 Patch release (2016-05-23) + +- **Annotations**: Annotations can now use a template variable as data source, closes [#5054](https://github.com/grafana/grafana/issues/5054) +- **Time picker**: Fixed issue timepicker and UTC when reading time from URL, fixes [#5078](https://github.com/grafana/grafana/issues/5078) +- **CloudWatch**: Support for Multiple Account by AssumeRole, closes [#3522](https://github.com/grafana/grafana/issues/3522) +- **Singlestat**: Fixed alignment and minimum height issue, fixes [#5113](https://github.com/grafana/grafana/issues/5113), fixes [#4679](https://github.com/grafana/grafana/issues/4679) +- **Share modal**: Fixed link when using grafana under dashboard sub url, fixes [#5109](https://github.com/grafana/grafana/issues/5109) +- **Prometheus**: Fixed bug in query editor that caused it not to load when reloading page, fixes [#5107](https://github.com/grafana/grafana/issues/5107) +- **Elasticsearch**: Fixed bug when template variable query returns numeric values, fixes [#5097](https://github.com/grafana/grafana/issues/5097), fixes [#5088](https://github.com/grafana/grafana/issues/5088) +- **Logging**: Fixed issue with reading logging level value, fixes [#5079](https://github.com/grafana/grafana/issues/5079) +- **Timepicker**: Fixed issue with timepicker and UTC when reading time from URL, fixes [#5078](https://github.com/grafana/grafana/issues/5078) +- **Docs**: Added docs for org & user preferences HTTP API, closes [#5069](https://github.com/grafana/grafana/issues/5069) +- **Plugin list panel**: Now shows correct enable state for apps when not enabled, fixes [#5068](https://github.com/grafana/grafana/issues/5068) +- **Elasticsearch**: Templating & Annotation queries that use template variables are now formatted correctly, fixes [#5135](https://github.com/grafana/grafana/issues/5135) + +# 3.0.2 Patch release (2016-05-16) + +- **Templating**: Fixed issue mixing row repeat and panel repeats, fixes [#4988](https://github.com/grafana/grafana/issues/4988) +- **Templating**: Fixed issue detecting dependencies in nested variables, fixes [#4987](https://github.com/grafana/grafana/issues/4987), fixes [#4986](https://github.com/grafana/grafana/issues/4986) +- **Graph**: Fixed broken PNG rendering in graph panel, fixes [#5025](https://github.com/grafana/grafana/issues/5025) +- **Graph**: Fixed broken xaxis on graph panel, fixes [#5024](https://github.com/grafana/grafana/issues/5024) + +- **Influxdb**: Fixes crash when hiding middle series, fixes [#5005](https://github.com/grafana/grafana/issues/5005) + +# 3.0.1 Stable (2016-05-11) + +### Bug fixes + +- **Templating**: Fixed issue with new data source variable not persisting current selected value, fixes [#4934](https://github.com/grafana/grafana/issues/4934) + +# 3.0.0-beta7 (2016-05-02) + +### Bug fixes + +- **Dashboard title**: Fixed max dashboard title width (media query) for large screens, fixes [#4859](https://github.com/grafana/grafana/issues/4859) +- **Annotations**: Fixed issue with entering annotation edit view, fixes [#4857](https://github.com/grafana/grafana/issues/4857) +- **Remove query**: Fixed issue with removing query for data sources without collapsible query editors, fixes [#4856](https://github.com/grafana/grafana/issues/4856) +- **Graphite PNG**: Fixed issue graphite png rendering option, fixes [#4864](https://github.com/grafana/grafana/issues/4864) +- **InfluxDB**: Fixed issue missing plus group by iconn, fixes [#4862](https://github.com/grafana/grafana/issues/4862) +- **Graph**: Fixes missing line mode for thresholds, fixes [#4902](https://github.com/grafana/grafana/pull/4902) + +### Enhancements + +- **InfluxDB**: Added new functions moving_average and difference to query editor, closes [#4698](https://github.com/grafana/grafana/issues/4698) + +# 3.0.0-beta6 (2016-04-29) + +### Enhancements + +- **Singlestat**: Support for gauges in singlestat panel. closes [#3688](https://github.com/grafana/grafana/pull/3688) +- **Templating**: Support for data source as variable, closes [#816](https://github.com/grafana/grafana/pull/816) + +### Bug fixes + +- **InfluxDB 0.12**: Fixed issue templating and `show tag values` query only returning tags for first measurement, fixes [#4726](https://github.com/grafana/grafana/issues/4726) +- **Templating**: Fixed issue with regex formatting when matching multiple values, fixes [#4755](https://github.com/grafana/grafana/issues/4755) +- **Templating**: Fixed issue with custom all value and escaping, fixes [#4736](https://github.com/grafana/grafana/issues/4736) +- **Dashlist**: Fixed issue dashboard list panel and caching tags, fixes [#4768](https://github.com/grafana/grafana/issues/4768) +- **Graph**: Fixed issue with unneeded scrollbar in legend for Firefox, fixes [#4760](https://github.com/grafana/grafana/issues/4760) +- **Table panel**: Fixed issue table panel formatting string array properties, fixes [#4791](https://github.com/grafana/grafana/issues/4791) +- **grafana-cli**: Improve error message when failing to install plugins due to corrupt response, fixes [#4651](https://github.com/grafana/grafana/issues/4651) +- **Singlestat**: Fixes prefix an postfix for gauges, fixes [#4812](https://github.com/grafana/grafana/issues/4812) +- **Singlestat**: Fixes auto-refresh on change for some options, fixes [#4809](https://github.com/grafana/grafana/issues/4809) + +### Breaking changes + +**Data Source Query Editors**: Issue [#3900](https://github.com/grafana/grafana/issues/3900) + +Query editors have been updated to use the new form styles. External data source plugins needs to be +updated to work. Sorry to introduce breaking change this late in beta phase. We wanted to get this change +in before 3.0 stable is released so we don't have to break data sources in next release (3.1). If you are +a data source plugin author and want help for how the new form styles work please ask for help in +slack channel (link to slack channel in readme). + +# 3.0.0-beta5 (2016-04-15) + +### Bug fixes + +- **grafana-cli**: Fixed issue grafana-cli tool, did not detect the right plugin dir, fixes [#4723](https://github.com/grafana/grafana/issues/4723) +- **Graph**: Fixed issue with light theme text color issue in tooltip, fixes [#4702](https://github.com/grafana/grafana/issues/4702) +- **Snapshot**: Fixed issue with empty snapshots, fixes [#4706](https://github.com/grafana/grafana/issues/4706) + +# 3.0.0-beta4 (2016-04-13) + +### Bug fixes + +- **Home dashboard**: Fixed issue with permission denied error on home dashboard, fixes [#4686](https://github.com/grafana/grafana/issues/4686) +- **Templating**: Fixed issue templating variables that use regex extraction, fixes [#4672](https://github.com/grafana/grafana/issues/4672) + +# 3.0.0-beta3 (2016-04-12) + +### Enhancements + +- **InfluxDB**: Changed multi query encoding to work with InfluxDB 0.11 & 0.12, closes [#4533](https://github.com/grafana/grafana/issues/4533) +- **Timepicker**: Add arrows and shortcuts for moving back and forth in current dashboard, closes [#119](https://github.com/grafana/grafana/issues/119) + +### Bug fixes + +- **Postgres**: Fixed page render crash when using postgres, fixes [#4558](https://github.com/grafana/grafana/issues/4558) +- **Table panel**: Fixed table panel bug when trying to show annotations in table panel, fixes [#4563](https://github.com/grafana/grafana/issues/4563) +- **App Config**: Fixed app config issue showing content of other app config, fixes [#4575](https://github.com/grafana/grafana/issues/4575) +- **Graph Panel**: Fixed legend option max not updating, fixes [#4601](https://github.com/grafana/grafana/issues/4601) +- **Graph Panel**: Fixed issue where newly added graph panels shared same axes config, fixes [#4582](https://github.com/grafana/grafana/issues/4582) +- **Graph Panel**: Fixed issue with axis labels overlapping Y-axis, fixes [#4626](https://github.com/grafana/grafana/issues/4626) +- **InfluxDB**: Fixed issue with templating query containing template variable, fixes [#4602](https://github.com/grafana/grafana/issues/4602) +- **Graph Panel**: Fixed issue with hiding series and stacking, fixes [#4557](https://github.com/grafana/grafana/issues/4557) +- **Graph Panel**: Fixed issue with legend height in table mode with few series, affected iframe embedding as well, fixes [#4640](https://github.com/grafana/grafana/issues/4640) + +# 3.0.0-beta2 (2016-04-04) + +### New Features (introduces since 3.0-beta1) + +- **Preferences**: Set home dashboard on user and org level, closes [#1678](https://github.com/grafana/grafana/issues/1678) +- **Preferences**: Set timezone on user and org level, closes [#3214](https://github.com/grafana/grafana/issues/3214), [#1200](https://github.com/grafana/grafana/issues/1200) +- **Preferences**: Set theme on user and org level, closes [#3214](https://github.com/grafana/grafana/issues/3214), [#1917](https://github.com/grafana/grafana/issues/1917) + +### Bug fixes + +- **Dashboard**: Fixed dashboard panel layout for mobile devices, fixes [#4529](https://github.com/grafana/grafana/issues/4529) +- **Table Panel**: Fixed issue with table panel sort, fixes [#4532](https://github.com/grafana/grafana/issues/4532) +- **Page Load Crash**: A data source with null jsonData would make Grafana fail to load page, fixes [#4536](https://github.com/grafana/grafana/issues/4536) +- **Metrics tab**: Fix for missing data source name in data source selector, fixes [#4541](https://github.com/grafana/grafana/issues/4540) +- **Graph**: Fix legend in table mode with series on right-y axis, fixes [#4551](https://github.com/grafana/grafana/issues/4551), [#1145](https://github.com/grafana/grafana/issues/1145) + +# 3.0.0-beta1 (2016-03-31) + +### New Features + +- **Playlists**: Playlists can now be persisted and started from urls, closes [#3655](https://github.com/grafana/grafana/issues/3655) +- **Metadata**: Settings panel now shows dashboard metadata, closes [#3304](https://github.com/grafana/grafana/issues/3304) +- **InfluxDB**: Support for policy selection in query editor, closes [#2018](https://github.com/grafana/grafana/issues/2018) +- **Snapshots UI**: Dashboard snapshots list can be managed through UI, closes[#1984](https://github.com/grafana/grafana/issues/1984) +- **Prometheus**: Prometheus annotation support, closes[#2883](https://github.com/grafana/grafana/pull/2883) +- **Cli**: New cli tool for downloading and updating plugins +- **Annotations**: Annotations can now contain links that can be clicked (you can navigate on to annotation popovers), closes [#1588](https://github.com/grafana/grafana/issues/1588) +- **Opentsdb**: Opentsdb 2.2 filters support, closes[#3077](https://github.com/grafana/grafana/issues/3077) + +### Breaking changes + +- **Plugin API**: Both data source and panel plugin api (and plugin.json schema) have been updated, requiring an update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info. +- **InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3523](https://github.com/grafana/grafana/issues/3523) +- **KairosDB** The data source is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3524](https://github.com/grafana/grafana/issues/3524) +- **Templating**: Templating value formats (glob/regex/pipe etc) are now handled automatically and not specified by the user, this makes variable values possible to reuse in many contexts. It can in some edge cases break existing dashboards that have template variables that do not reload on dashboard load. To fix any issue just go into template variable options and update the variable (so it's values are reloaded.). + +### Enhancements + +- **LDAP**: Support for nested LDAP Groups, closes [#4401](https://github.com/grafana/grafana/issues/4401), [#3808](https://github.com/grafana/grafana/issues/3808) +- **Sessions**: Support for memcached as session storage, closes [#3458](https://github.com/grafana/grafana/issues/3458) +- **mysql**: Grafana now supports ssl for mysql, closes [#3584](https://github.com/grafana/grafana/issues/3584) +- **snapshot**: Annotations are now included in snapshots, closes [#3635](https://github.com/grafana/grafana/issues/3635) +- **Admin**: Admin can now have global overview of Grafana setup, closes [#3812](https://github.com/grafana/grafana/issues/3812) +- **graph**: Right side legend height is now fixed at row height, closes [#1277](https://github.com/grafana/grafana/issues/1277) +- **Table**: All content in table panel is now html escaped, closes [#3673](https://github.com/grafana/grafana/issues/3673) +- **graph**: Template variables can now be used in TimeShift and TimeFrom, closes[#1960](https://github.com/grafana/grafana/issues/1960) +- **Tooltip**: Optionally add milliseconds to timestamp in tool tip, closes[#2248](https://github.com/grafana/grafana/issues/2248) +- **Opentsdb**: Support milliseconds when using openTSDB data source, closes [#2865](https://github.com/grafana/grafana/issues/2865) +- **Opentsdb**: Add support for annotations, closes[#664](https://github.com/grafana/grafana/issues/664) + +### Bug fixes + +- **Playlist**: Fix for memory leak when running a playlist, closes [#3794](https://github.com/grafana/grafana/pull/3794) +- **InfluxDB**: Fix for InfluxDB and table panel when using Format As Table and having group by time, fixes [#3928](https://github.com/grafana/grafana/issues/3928) +- **Panel Time shift**: Fix for panel time range and using dashboard times like `Today` and `This Week`, fixes [#3941](https://github.com/grafana/grafana/issues/3941) +- **Row repeat**: Repeated rows will now appear next to each other and not by the bottom of the dashboard, fixes [#3942](https://github.com/grafana/grafana/issues/3942) +- **Png renderer**: Fix for phantomjs path on windows, fixes [#3657](https://github.com/grafana/grafana/issues/3657) + +# 2.6.1 (unreleased, 2.6.x branch) + +### New Features + +- **Elasticsearch**: Support for derivative unit option, closes [#3512](https://github.com/grafana/grafana/issues/3512) + +### Bug fixes + +- **Graph Panel**: Fixed typehead when adding series style override, closes [#3554](https://github.com/grafana/grafana/issues/3554) + +# 2.6.0 (2015-12-14) + +### New Features + +- **Elasticsearch**: Support for pipeline aggregations Moving average and derivative, closes [#2715](https://github.com/grafana/grafana/issues/2715) +- **Elasticsearch**: Support for inline script and missing options for metrics, closes [#3500](https://github.com/grafana/grafana/issues/3500) +- **Syslog**: Support for syslog logging, closes [#3161](https://github.com/grafana/grafana/pull/3161) +- **Timepicker**: Always show refresh button even with refresh rate, closes [#3498](https://github.com/grafana/grafana/pull/3498) +- **Login**: Make it possible to change the login hint on the login page, closes [#2571](https://github.com/grafana/grafana/pull/2571) + +### Bug Fixes + +- **metric editors**: Fix for clicking typeahead auto dropdown option, fixes [#3428](https://github.com/grafana/grafana/issues/3428) +- **influxdb**: Fixed issue showing Group By label only on first query, fixes [#3453](https://github.com/grafana/grafana/issues/3453) +- **logging**: Add more verbose info logging for http requests, closes [#3405](https://github.com/grafana/grafana/pull/3405) + +# 2.6.0-Beta1 (2015-12-04) + +### New Table Panel + +- **table**: New powerful and flexible table panel, closes [#215](https://github.com/grafana/grafana/issues/215) + +### Enhancements + +- **CloudWatch**: Support for multiple AWS Credentials, closes [#3053](https://github.com/grafana/grafana/issues/3053), [#3080](https://github.com/grafana/grafana/issues/3080) +- **Elasticsearch**: Support for dynamic daily indices for annotations, closes [#3061](https://github.com/grafana/grafana/issues/3061) +- **Elasticsearch**: Support for setting min_doc_count for date histogram, closes [#3416](https://github.com/grafana/grafana/issues/3416) +- **Graph Panel**: Option to hide series with all zeroes from legend and tooltip, closes [#1381](https://github.com/grafana/grafana/issues/1381), [#3336](https://github.com/grafana/grafana/issues/3336) + +### Bug Fixes + +- **cloudwatch**: fix for handling of period for long time ranges, fixes [#3086](https://github.com/grafana/grafana/issues/3086) +- **dashboard**: fix for collapse row by clicking on row title, fixes [#3065](https://github.com/grafana/grafana/issues/3065) +- **influxdb**: fix for relative time ranges `last x months` and `last x years`, fixes [#3067](https://github.com/grafana/grafana/issues/3067) +- **graph**: layout fix for color picker when right side legend was enabled, fixes [#3093](https://github.com/grafana/grafana/issues/3093) +- **elasticsearch**: disabling elastic query (via eye) caused error, fixes [#3300](https://github.com/grafana/grafana/issues/3300) + +### Breaking changes + +- **elasticsearch**: Manual json edited queries are not supported any more (They very barely worked in 2.5) + +# 2.5 (2015-10-28) + +**New Feature: Mix data sources** + +- A built in data source is now available named `-- Mixed --`, When picked in the metrics tab, + it allows you to add queries of different data source types & instances to the same graph/panel! + [Issue #436](https://github.com/grafana/grafana/issues/436) + +**New Feature: Elasticsearch Metrics Query Editor and Viz Support** + +- Feature rich query editor and processing features enables you to issues all kind of metric queries to Elasticsearch +- See [Issue #1034](https://github.com/grafana/grafana/issues/1034) for more info. + +**New Feature: New and much improved time picker** + +- Support for quick ranges like `Today`, `This day last week`, `This week`, `The day so far`, etc. +- Improved UI and improved support for UTC, [Issue #2761](https://github.com/grafana/grafana/issues/2761) for more info. + +**User Onboarding** + +- Org admin can now send email invites (or invite links) to people who are not yet Grafana users +- Sign up flow now supports email verification (if enabled) +- See [Issue #2353](https://github.com/grafana/grafana/issues/2353) for more info. + +**Other new Features && Enhancements** + +- [Pull #2720](https://github.com/grafana/grafana/pull/2720). Admin: Initial basic quota support (per Org) +- [Issue #2577](https://github.com/grafana/grafana/issues/2577). Panel: Resize handles in panel bottom right corners for easy width and height change +- [Issue #2457](https://github.com/grafana/grafana/issues/2457). Admin: admin page for all grafana organizations (list / edit view) +- [Issue #1186](https://github.com/grafana/grafana/issues/1186). Time Picker: New option `today`, will set time range from midnight to now +- [Issue #2647](https://github.com/grafana/grafana/issues/2647). InfluxDB: You can now set group by time interval on each query +- [Issue #2599](https://github.com/grafana/grafana/issues/2599). InfluxDB: Improved alias support, you can now use the `AS` clause for each select statement +- [Issue #2708](https://github.com/grafana/grafana/issues/2708). InfluxDB: You can now set math expression for select clauses. +- [Issue #1575](https://github.com/grafana/grafana/issues/1575). Drilldown link: now you can click on the external link icon in the panel header to access drilldown links! +- [Issue #1646](https://github.com/grafana/grafana/issues/1646). OpenTSDB: Fetch list of aggregators from OpenTSDB +- [Issue #2955](https://github.com/grafana/grafana/issues/2955). Graph: More axis units (Length, Volume, Temperature, Pressure, etc), thanks @greglook +- [Issue #2928](https://github.com/grafana/grafana/issues/2928). LDAP: Support for searching for groups memberships, i.e. POSIX (no memberOf) schemas, also multiple ldap servers, and root ca cert, thanks @abligh + +**Fixes** + +- [Issue #2413](https://github.com/grafana/grafana/issues/2413). InfluxDB 0.9: Fix for handling empty series object in response from influxdb +- [Issue #2574](https://github.com/grafana/grafana/issues/2574). Snapshot: Fix for snapshot with expire 7 days option, 7 days option not correct, was 7 hours +- [Issue #2568](https://github.com/grafana/grafana/issues/2568). AuthProxy: Fix for server side rendering of panel when using auth proxy +- [Issue #2490](https://github.com/grafana/grafana/issues/2490). Graphite: Dashboard import was broken in 2.1 and 2.1.1, working now +- [Issue #2565](https://github.com/grafana/grafana/issues/2565). TimePicker: Fix for when you applied custom time range it did not refresh dashboard +- [Issue #2563](https://github.com/grafana/grafana/issues/2563). Annotations: Fixed issue when html sanitizer fails for title to annotation body, now fallbacks to html escaping title and text +- [Issue #2564](https://github.com/grafana/grafana/issues/2564). Templating: Another attempt at fixing #2534 (Init multi value template var used in repeat panel from url) +- [Issue #2620](https://github.com/grafana/grafana/issues/2620). Graph: multi series tooltip did no highlight correct point when stacking was enabled and series were of different resolution +- [Issue #2636](https://github.com/grafana/grafana/issues/2636). InfluxDB: Do no show template vars in dropdown for tag keys and group by keys +- [Issue #2604](https://github.com/grafana/grafana/issues/2604). InfluxDB: More alias options, can now use `$[0-9]` syntax to reference part of a measurement name (separated by dots) + +**Breaking Changes** + +- Notice to makers/users of custom data sources, there is a minor breaking change in 2.2 that + require an update to custom data sources for them to work in 2.2. [Read this doc](https://github.com/grafana/grafana/tree/master/docs/sources/datasources/plugin_api.md) for more on the + data source api change. +- Data source api changes, [PLUGIN_CHANGES.md](https://github.com/grafana/grafana/blob/master/public/app/plugins/PLUGIN_CHANGES.md) +- The duplicate query function used in data source editors is changed, and moveMetricQuery function was renamed + +**Tech (Note for devs)** +Started using Typescript (transpiled to ES5), uncompiled typescript files and less files are in public folder (in source tree) +This folder is never modified by build steps. Compiled css and javascript files are put in public_gen, all other files +that do not undergo transformation are just copied from public to public_gen, it is public_gen that is used by grafana-server +if it is found. + +Grunt & Watch tasks: + +- `grunt` : default task, will remove public_gen, copy over all files from public, do less & typescript compilation +- `grunt watch`: will watch for changes to less, and typescript files and compile them to public_gen, and for other files it will just copy them to public_gen + +# 2.1.3 (2015-08-24) + +**Fixes** + +- [Issue #2580](https://github.com/grafana/grafana/issues/2580). Packaging: ldap.toml was not marked as config file and could be overwritten in upgrade +- [Issue #2564](https://github.com/grafana/grafana/issues/2564). Templating: Another attempt at fixing #2534 (Init multi value template var used in repeat panel from url) + +# 2.1.2 (2015-08-20) + +**Fixes** + +- [Issue #2558](https://github.com/grafana/grafana/issues/2558). DragDrop: Fix for broken drag drop behavior +- [Issue #2534](https://github.com/grafana/grafana/issues/2534). Templating: fix for setting template variable value via url and having repeated panels or rows + +# 2.1.1 (2015-08-11) + +**Fixes** + +- [Issue #2443](https://github.com/grafana/grafana/issues/2443). Templating: Fix for buggy repeat row behavior when combined with with repeat panel due to recent change before 2.1 release +- [Issue #2442](https://github.com/grafana/grafana/issues/2442). Templating: Fix text panel when using template variables in text in in repeated panel +- [Issue #2446](https://github.com/grafana/grafana/issues/2446). InfluxDB: Fix for using template vars inside alias field (InfluxDB 0.9) +- [Issue #2460](https://github.com/grafana/grafana/issues/2460). SinglestatPanel: Fix to handle series with no data points +- [Issue #2461](https://github.com/grafana/grafana/issues/2461). LDAP: Fix for ldap users with empty email address +- [Issue #2484](https://github.com/grafana/grafana/issues/2484). Graphite: Fix bug when using series ref (#A-Z) and referenced series is hidden in query editor. +- [Issue #1896](https://github.com/grafana/grafana/issues/1896). Postgres: Dashboard search is now case insensitive when using Postgres + +**Enhancements** + +- [Issue #2477](https://github.com/grafana/grafana/issues/2477). InfluxDB(0.9): Added more condition operators (`<`, `>`, `<>`, `!~`), thx @thuck +- [Issue #2483](https://github.com/grafana/grafana/issues/2484). InfluxDB(0.9): Use \$col as option in alias patterns, thx @thuck + +# 2.1.0 (2015-08-04) + +**Data sources** + +- [Issue #1525](https://github.com/grafana/grafana/issues/1525). InfluxDB: Full support for InfluxDB 0.9 with new adapted query editor +- [Issue #2191](https://github.com/grafana/grafana/issues/2191). KariosDB: Grafana now ships with a KariosDB data source plugin, thx @masaori335 +- [Issue #1177](https://github.com/grafana/grafana/issues/1177). OpenTSDB: Limit tags by metric, OpenTSDB config option tsd.core.meta.enable_realtime_ts must enabled for OpenTSDB lookup api +- [Issue #1250](https://github.com/grafana/grafana/issues/1250). OpenTSDB: Support for template variable values lookup queries + +**New dashboard features** + +- [Issue #1144](https://github.com/grafana/grafana/issues/1144). Templating: You can now select multiple template variables values at the same time. +- [Issue #1922](https://github.com/grafana/grafana/issues/1922). Templating: Specify multiple variable values via URL params. +- [Issue #1888](https://github.com/grafana/grafana/issues/1144). Templating: Repeat panel or row for each selected template variable value +- [Issue #1888](https://github.com/grafana/grafana/issues/1944). Dashboard: Custom Navigation links & dynamic links to related dashboards +- [Issue #590](https://github.com/grafana/grafana/issues/590). Graph: Define series color using regex rule +- [Issue #2162](https://github.com/grafana/grafana/issues/2162). Graph: New series style override, negative-y transform and stack groups +- [Issue #2096](https://github.com/grafana/grafana/issues/2096). Dashboard list panel: Now supports search by multiple tags +- [Issue #2203](https://github.com/grafana/grafana/issues/2203). Singlestat: Now support string values + +**User or Organization admin** + +- [Issue #1899](https://github.com/grafana/grafana/issues/1899). Organization: You can now update the organization user role directly (without removing and readding the organization user). +- [Issue #2088](https://github.com/grafana/grafana/issues/2088). Roles: New user role `Read Only Editor` that replaces the old `Viewer` role behavior + +**Backend** + +- [Issue #2218](https://github.com/grafana/grafana/issues/2218). Auth: You can now authenticate against api with username / password using basic auth +- [Issue #2095](https://github.com/grafana/grafana/issues/2095). Search: Search now supports filtering by multiple dashboard tags +- [Issue #1905](https://github.com/grafana/grafana/issues/1905). GitHub OAuth: You can now configure a GitHub team membership requirement, thx @dewski +- [Issue #2052](https://github.com/grafana/grafana/issues/2052). GitHub OAuth: You can now configure a GitHub organization requirement, thx @indrekj +- [Issue #1891](https://github.com/grafana/grafana/issues/1891). Security: New config option to disable the use of gravatar for profile images +- [Issue #1921](https://github.com/grafana/grafana/issues/1921). Auth: Support for user authentication via reverse proxy header (like X-Authenticated-User, or X-WEBAUTH-USER) +- [Issue #960](https://github.com/grafana/grafana/issues/960). Search: Backend can now index a folder with json files, will be available in search (saving back to folder is not supported, this feature is meant for static generated json dashboards) + +**Breaking changes** + +- [Issue #1826](https://github.com/grafana/grafana/issues/1826). User role 'Viewer' are now prohibited from entering edit mode (and doing other transient dashboard edits). A new role `Read Only Editor` will replace the old Viewer behavior +- [Issue #1928](https://github.com/grafana/grafana/issues/1928). HTTP API: GET /api/dashboards/db/:slug response changed property `model` to `dashboard` to match the POST request naming +- Backend render URL changed from `/render/dashboard/solo` `render/dashboard-solo/` (in order to have consistent dashboard url `/dashboard/:type/:slug`) +- Search HTTP API response has changed (simplified), tags list moved to separate HTTP resource URI +- Data source HTTP api breaking change, ADD data source is now POST /api/datasources/, update is now PUT /api/datasources/:id + +**Fixes** + +- [Issue #2185](https://github.com/grafana/grafana/issues/2185). Graph: fixed PNG rendering of panels with legend table to the right +- [Issue #2163](https://github.com/grafana/grafana/issues/2163). Backend: Load dashboards with capital letters in the dashboard url slug (url id) + +# 2.0.3 (unreleased - 2.0.x branch) + +**Fixes** + +- [Issue #1872](https://github.com/grafana/grafana/issues/1872). Firefox/IE issue, invisible text in dashboard search fixed +- [Issue #1857](https://github.com/grafana/grafana/issues/1857). /api/login/ping Fix for issue when behind reverse proxy and subpath +- [Issue #1863](https://github.com/grafana/grafana/issues/1863). MySQL: Dashboard.data column type changed to mediumtext (sql migration added) + +# 2.0.2 (2015-04-22) + +**Fixes** + +- [Issue #1832](https://github.com/grafana/grafana/issues/1832). Graph Panel + Legend Table mode: Many series caused zero height graph, now legend will never reduce the height of the graph below 50% of row height. +- [Issue #1846](https://github.com/grafana/grafana/issues/1846). Snapshots: Fixed issue with snapshotting dashboards with an interval template variable +- [Issue #1848](https://github.com/grafana/grafana/issues/1848). Panel timeshift: You can now use panel timeshift without a relative time override + +# 2.0.1 (2015-04-20) + +**Fixes** + +- [Issue #1784](https://github.com/grafana/grafana/issues/1784). Data source proxy: Fixed issue with using data source proxy when grafana is behind nginx suburl +- [Issue #1749](https://github.com/grafana/grafana/issues/1749). Graph Panel: Table legends are now visible when rendered to PNG +- [Issue #1786](https://github.com/grafana/grafana/issues/1786). Graph Panel: Legend in table mode now aligns, graph area is reduced depending on how many series +- [Issue #1734](https://github.com/grafana/grafana/issues/1734). Support for unicode / international characters in dashboard title (improved slugify) +- [Issue #1782](https://github.com/grafana/grafana/issues/1782). GitHub OAuth: Now works with GitHub for Enterprise, thanks @williamjoy +- [Issue #1780](https://github.com/grafana/grafana/issues/1780). Dashboard snapshot: Should not require login to view snapshot, Fixes #1780 + +# 2.0.0-Beta3 (2015-04-12) + +**RPM / DEB Package changes (to follow HFS)** + +- binary name changed to grafana-server +- does not install to `/opt/grafana` any more, installs to `/usr/share/grafana` +- binary to `/usr/sbin/grafana-server` +- init.d script improvements, renamed to `/etc/init.d/grafana-server` +- added default file with environment variables, + + - `/etc/default/grafana-server` (deb/ubuntu) + - `/etc/sysconfig/grafana-server` (centos/redhat) + +- added systemd service file, tested on debian jessie and centos7 +- config file in same location `/etc/grafana/grafana.ini` (now complete config file but with every setting commented out) +- data directory (where sqlite3) file is stored is now by default `/var/lib/grafana` +- no symlinking current to versions anymore +- For more info see [Issue #1758](https://github.com/grafana/grafana/issues/1758). + +**Config breaking change (setting rename)** + +- `[log] root_path` has changed to `[paths] logs` + +# 2.0.0-Beta2 (...) + +**Enhancements** + +- [Issue #1701](https://github.com/grafana/grafana/issues/1701). Share modal: Override UI theme via URL param for Share link, rendered panel, or embedded panel +- [Issue #1660](https://github.com/grafana/grafana/issues/1660). OAuth: Specify allowed email address domains for google or and github oauth logins + +**Fixes** + +- [Issue #1649](https://github.com/grafana/grafana/issues/1649). HTTP API: grafana /render calls nows with api keys +- [Issue #1667](https://github.com/grafana/grafana/issues/1667). Data source proxy & session timeout fix (caused 401 Unauthorized error after a while) +- [Issue #1707](https://github.com/grafana/grafana/issues/1707). Unsaved changes: Do not show for snapshots, scripted and file based dashboards +- [Issue #1703](https://github.com/grafana/grafana/issues/1703). Unsaved changes: Do not show for users with role `Viewer` +- [Issue #1675](https://github.com/grafana/grafana/issues/1675). Data source proxy: Fixed issue with Gzip enabled and data source proxy +- [Issue #1681](https://github.com/grafana/grafana/issues/1681). MySQL session: fixed problem using mysql as session store +- [Issue #1671](https://github.com/grafana/grafana/issues/1671). Data sources: Fixed issue with changing default data source (should not require full page load to take effect, now fixed) +- [Issue #1685](https://github.com/grafana/grafana/issues/1685). Search: Dashboard results should be sorted alphabetically +- [Issue #1673](https://github.com/grafana/grafana/issues/1673). Basic auth: Fixed issue when using basic auth proxy infront of Grafana + +# 2.0.0-Beta1 (2015-03-30) + +**Important Note** + +Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated backend server. Please read the [Documentation](http://docs.grafana.org) for more detailed about this SIGNIFICANT change to Grafana + +**New features** + +- [Issue #1623](https://github.com/grafana/grafana/issues/1623). Share Dashboard: Dashboard snapshot sharing (dash and data snapshot), save to local or save to public snapshot dashboard snapshots.raintank.io site +- [Issue #1622](https://github.com/grafana/grafana/issues/1622). Share Panel: The share modal now has an embed option, gives you an iframe that you can use to embed a single graph on another web site +- [Issue #718](https://github.com/grafana/grafana/issues/718). Dashboard: When saving a dashboard and another user has made changes in between the user is prompted with a warning if he really wants to overwrite the other's changes +- [Issue #1331](https://github.com/grafana/grafana/issues/1331). Graph & Singlestat: New axis/unit format selector and more units (kbytes, Joule, Watt, eV), and new design for graph axis & grid tab and single stat options tab views +- [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change `now` to be for example `now-1m`, useful when you want to ignore last minute because it contains incomplete data +- [Issue #171](https://github.com/grafana/grafana/issues/171). Panel: Different time periods, panels can override dashboard relative time and/or add a time shift +- [Issue #1488](https://github.com/grafana/grafana/issues/1488). Dashboard: Clone dashboard / Save as +- [Issue #1458](https://github.com/grafana/grafana/issues/1458). User: persisted user option for dark or light theme (no longer an option on a dashboard) +- [Issue #452](https://github.com/grafana/grafana/issues/452). Graph: Adds logarithmic scale option for base 10, base 16 and base 1024 + +**Enhancements** + +- [Issue #1366](https://github.com/grafana/grafana/issues/1366). Graph & Singlestat: Support for additional units, Fahrenheit (°F) and Celsius (°C), Humidity (%H), kW, watt-hour (Wh), kilowatt-hour (kWh), velocities (m/s, km/h, mpg, knot) +- [Issue #978](https://github.com/grafana/grafana/issues/978). Graph: Shared tooltip improvement, can now support metrics of different resolution/intervals +- [Issue #1297](https://github.com/grafana/grafana/issues/1297). Graphite: Added cumulative and minimumBelow graphite functions +- [Issue #1296](https://github.com/grafana/grafana/issues/1296). InfluxDB: Auto escape column names with special characters. Thanks @steven-aerts +- [Issue #1321](https://github.com/grafana/grafana/issues/1321). SingleStatPanel: You can now use template variables in pre & postfix +- [Issue #599](https://github.com/grafana/grafana/issues/599). Graph: Added right y axis label setting and graph support +- [Issue #1253](https://github.com/grafana/grafana/issues/1253). Graph & Singlestat: Users can now set decimal precision for legend and tooltips (override auto precision) +- [Issue #1255](https://github.com/grafana/grafana/issues/1255). Templating: Dashboard will now wait to load until all template variables that have refresh on load set or are initialized via url to be fully loaded and so all variables are in valid state before panels start issuing metric requests. +- [Issue #1344](https://github.com/grafana/grafana/issues/1344). OpenTSDB: Alias patterns (reference tag values), syntax is: \$tag_tagname or [[tag_tagname]] + +**Fixes** + +- [Issue #1298](https://github.com/grafana/grafana/issues/1298). InfluxDB: Fix handling of empty array in templating variable query +- [Issue #1309](https://github.com/grafana/grafana/issues/1309). Graph: Fixed issue when using zero as a grid threshold +- [Issue #1345](https://github.com/grafana/grafana/issues/1345). UI: Fixed position of confirm modal when scrolled down +- [Issue #1372](https://github.com/grafana/grafana/issues/1372). Graphite: Fix for nested complex queries, where a query references a query that references another query (ie the #[A-Z] syntax) +- [Issue #1363](https://github.com/grafana/grafana/issues/1363). Templating: Fix to allow custom template variables to contain white space, now only splits on ',' +- [Issue #1359](https://github.com/grafana/grafana/issues/1359). Graph: Fix for all series tooltip showing series with all null values when `Hide Empty` option is enabled +- [Issue #1497](https://github.com/grafana/grafana/issues/1497). Dashboard: Fixed memory leak when switching dashboards + +**Changes** + +- Dashboard title change & save will no longer create a new dashboard, it will just change the title. + +**OpenTSDB breaking change** + +- [Issue #1438](https://github.com/grafana/grafana/issues/1438). OpenTSDB: Automatic downsample interval passed to OpenTSDB (depends on timespan and graph width) +- NOTICE, Downsampling is now enabled by default, so if you have not picked a downsample aggregator in your metric query do so or your graphs will be misleading +- This will make Grafana a lot quicker for OpenTSDB users when viewing large time spans without having to change the downsample interval manually. + +**Tech** + +- [Issue #1311](https://github.com/grafana/grafana/issues/1311). Tech: Updated Font-Awesome from 3.2 to 4.2 + +# 1.9.1 (2014-12-29) + +**Enhancements** + +- [Issue #1028](https://github.com/grafana/grafana/issues/1028). Graph: New legend option `hideEmpty` to hide series with only null values from legend +- [Issue #1242](https://github.com/grafana/grafana/issues/1242). OpenTSDB: Downsample query field now supports interval template variable +- [Issue #1126](https://github.com/grafana/grafana/issues/1126). InfluxDB: Support more than 10 series name segments when using alias `$number` patterns + +**Fixes** + +- [Issue #1251](https://github.com/grafana/grafana/issues/1251). Graph: Fix for y axis and scaled units (GiB etc) caused rounding, for example 400 GiB instead of 378 GiB +- [Issue #1199](https://github.com/grafana/grafana/issues/1199). Graph: fix for series tooltip when one series is hidden/disabled +- [Issue #1207](https://github.com/grafana/grafana/issues/1207). Graphite: movingAverage / movingMedian parameter type improvement, now handles int and interval parameter + +# 1.9.0 (2014-12-02) + +**Enhancements** + +- [Issue #1130](https://github.com/grafana/grafana/issues/1130). SinglestatPanel: Added null point handling, and value to text mapping + +**Fixes** + +- [Issue #1087](https://github.com/grafana/grafana/issues/1087). Panel: Fixed IE9 crash due to angular drag drop +- [Issue #1093](https://github.com/grafana/grafana/issues/1093). SingleStatPanel: Fixed position for drilldown link tooltip when dashboard requires scrolling +- [Issue #1095](https://github.com/grafana/grafana/issues/1095). DrilldownLink: template variables in params property was not interpolated +- [Issue #1114](https://github.com/grafana/grafana/issues/1114). Graphite: Lexer fix, allow equal sign (=) in metric paths +- [Issue #1136](https://github.com/grafana/grafana/issues/1136). Graph: Fix to legend value Max and negative values +- [Issue #1150](https://github.com/grafana/grafana/issues/1150). SinglestatPanel: Fixed absolute drilldown link issue +- [Issue #1123](https://github.com/grafana/grafana/issues/1123). Firefox: Workaround for Firefox bug, caused input text fields to not be selectable and not have placeable cursor +- [Issue #1108](https://github.com/grafana/grafana/issues/1108). Graph: Fix for tooltip series order when series draw order was changed with zindex property + +# 1.9.0-rc1 (2014-11-17) + +**UI Improvements** + +- [Issue #770](https://github.com/grafana/grafana/issues/770). UI: Panel dropdown menu replaced with a new panel menu + +**Graph** + +- [Issue #877](https://github.com/grafana/grafana/issues/877). Graph: Smart auto decimal precision when using scaled unit formats +- [Issue #850](https://github.com/grafana/grafana/issues/850). Graph: Shared tooltip that shows multiple series & crosshair line, thx @toni-moreno +- [Issue #940](https://github.com/grafana/grafana/issues/940). Graph: New series style override option "Fill below to", useful to visualize max & min as a shadow for the mean +- [Issue #1030](https://github.com/grafana/grafana/issues/1030). Graph: Legend table display/look changed, now includes column headers for min/max/avg, and full width (unless on right side) +- [Issue #861](https://github.com/grafana/grafana/issues/861). Graph: Export graph time series data as csv file + +**New Panels** + +- [Issue #951](https://github.com/grafana/grafana/issues/951). SingleStat: New singlestat panel + +**Misc** + +- [Issue #864](https://github.com/grafana/grafana/issues/846). Panel: Share panel feature, get a link to panel with the current time range +- [Issue #938](https://github.com/grafana/grafana/issues/938). Panel: Plugin panels now reside outside of app/panels directory +- [Issue #952](https://github.com/grafana/grafana/issues/952). Help: Shortcut "?" to open help modal with list of all shortcuts +- [Issue #991](https://github.com/grafana/grafana/issues/991). ScriptedDashboard: data source services are now available in scripted dashboards, you can query data source for metric keys, generate dashboards, and even save them in a scripted dashboard (see scripted_gen_and_save.js for example) +- [Issue #1041](https://github.com/grafana/grafana/issues/1041). Panel: All panels can now have links to other dashboards or absolute links, these links are available in the panel menu. + +**Changes** + +- [Issue #1007](https://github.com/grafana/grafana/issues/1007). Graph: Series hide/show toggle changed to be default exclusive, so clicking on a series name will show only that series. (SHIFT or meta)+click will toggle hide/show. + +**OpenTSDB** + +- [Issue #930](https://github.com/grafana/grafana/issues/930). OpenTSDB: Adding counter max and counter reset value to open tsdb query editor, thx @rsimiciuc +- [Issue #917](https://github.com/grafana/grafana/issues/917). OpenTSDB: Templating support for OpenTSDB series name and tags, thx @mchataigner + +**InfluxDB** + +- [Issue #714](https://github.com/grafana/grafana/issues/714). InfluxDB: Support for sub second resolution graphs + +**Fixes** + +- [Issue #925](https://github.com/grafana/grafana/issues/925). Graph: bar width calculation fix for some edge cases (bars would render on top of each other) +- [Issue #505](https://github.com/grafana/grafana/issues/505). Graph: fix for second y axis tick unit labels wrapping on the next line +- [Issue #987](https://github.com/grafana/grafana/issues/987). Dashboard: Collapsed rows became invisible when hide controls was enabled + +======= + +# 1.8.1 (2014-09-30) + +**Fixes** + +- [Issue #855](https://github.com/grafana/grafana/issues/855). Graph: Fix for scroll issue in graph edit mode when dropdown goes below screen +- [Issue #847](https://github.com/grafana/grafana/issues/847). Graph: Fix for series draw order not being the same after hiding/unhiding series +- [Issue #851](https://github.com/grafana/grafana/issues/851). Annotations: Fix for annotations not reloaded when switching between 2 dashboards with annotations +- [Issue #846](https://github.com/grafana/grafana/issues/846). Edit panes: Issue when open row or json editor when scrolled down the page, unable to scroll and you did not see editor +- [Issue #840](https://github.com/grafana/grafana/issues/840). Import: Fixes to import from json file and import from graphite. Issues was lingering state from previous dashboard. +- [Issue #859](https://github.com/grafana/grafana/issues/859). InfluxDB: Fix for bug when saving dashboard where title is the same as slugified url id +- [Issue #852](https://github.com/grafana/grafana/issues/852). White theme: Fixes for hidden series legend text and disabled annotations color + +# 1.8.0 (2014-09-22) + +Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-released) for an overview of all improvements. + +**Fixes** + +- [Issue #802](https://github.com/grafana/grafana/issues/802). Annotations: Fix when using InfluxDB data source +- [Issue #795](https://github.com/grafana/grafana/issues/795). Chrome: Fix for display issue in chrome beta & chrome canary when entering edit mode +- [Issue #818](https://github.com/grafana/grafana/issues/818). Graph: Added percent y-axis format +- [Issue #828](https://github.com/grafana/grafana/issues/828). Elasticsearch: saving new dashboard with title equal to slugified url would cause it to deleted. +- [Issue #830](https://github.com/grafana/grafana/issues/830). Annotations: Fix for elasticsearch annotations and mapping nested fields + +# 1.8.0-RC1 (2014-09-12) + +**UI polish / changes** + +- [Issue #725](https://github.com/grafana/grafana/issues/725). UI: All modal editors are removed and replaced by an edit pane under menu. The look of editors is also updated and polished. Search dropdown is also shown as pane under menu and has seen some UI polish. + +**Filtering/Templating feature overhaul** + +- Filtering renamed to Templating, and filter items to variables +- Filter editing has gotten its own edit pane with much improved UI and options +- [Issue #296](https://github.com/grafana/grafana/issues/296). Templating: Can now retrieve variable values from a non-default data source +- [Issue #219](https://github.com/grafana/grafana/issues/219). Templating: Template variable value selection is now a typeahead autocomplete dropdown +- [Issue #760](https://github.com/grafana/grafana/issues/760). Templating: Extend template variable syntax to include \$variable syntax replacement +- [Issue #234](https://github.com/grafana/grafana/issues/234). Templating: Interval variable type for time intervals summarize/group by parameter, included "auto" option, and auto step counts option. +- [Issue #262](https://github.com/grafana/grafana/issues/262). Templating: Ability to use template variables for function parameters via custom variable type, can be used as parameter for movingAverage or scaleToSeconds for example +- [Issue #312](https://github.com/grafana/grafana/issues/312). Templating: Can now use template variables in panel titles +- [Issue #613](https://github.com/grafana/grafana/issues/613). Templating: Full support for InfluxDB, filter by part of series names, extract series substrings, nested queries, multiple where clauses! +- Template variables can be initialized from url, with var-my_varname=value, breaking change, before it was just my_varname. +- Templating and url state sync has some issues that are not solved for this release, see [Issue #772](https://github.com/grafana/grafana/issues/772) for more details. + +**InfluxDB Breaking changes** + +- To better support templating, fill(0) and group by time low limit some changes has been made to the editor and query model schema +- Currently some of these changes are breaking +- If you used custom condition filter you need to open the graph in edit mode, the editor will update the schema, and the queries should work again +- If you used a raw query you need to remove the time filter and replace it with \$timeFilter (this is done automatically when you switch from query editor to raw query, but old raw queries needs to updated) +- If you used group by and later removed the group by the graph could break, open in editor and should correct it +- InfluxDB annotation queries that used [[timeFilter]] should be updated to use \$timeFilter syntax instead +- Might write an upgrade tool to update dashboards automatically, but right now master (1.8) includes the above breaking changes + +**InfluxDB query editor enhancements** + +- [Issue #756](https://github.com/grafana/grafana/issues/756). InfluxDB: Add option for fill(0) and fill(null), integrated help in editor for why this option is important when stacking series +- [Issue #743](https://github.com/grafana/grafana/issues/743). InfluxDB: A group by time option for all queries in graph panel that supports a low limit for auto group by time, very important for stacking and fill(0) +- The above to enhancements solves the problems associated with stacked bars and lines when points are missing, these issues are solved: +- [Issue #673](https://github.com/grafana/grafana/issues/673). InfluxDB: stacked bars missing intermediate data points, unless lines also enabled +- [Issue #674](https://github.com/grafana/grafana/issues/674). InfluxDB: stacked chart ignoring series without latest values +- [Issue #534](https://github.com/grafana/grafana/issues/534). InfluxDB: No order in stacked bars mode + +**New features and improvements** + +- [Issue #117](https://github.com/grafana/grafana/issues/117). Graphite: Graphite query builder can now handle functions that multiple series as arguments! +- [Issue #281](https://github.com/grafana/grafana/issues/281). Graphite: Metric node/segment selection is now a textbox with autocomplete dropdown, allow for custom glob expression for single node segment without entering text editor mode. +- [Issue #304](https://github.com/grafana/grafana/issues/304). Dashboard: View dashboard json, edit/update any panel using json editor, makes it possible to quickly copy a graph from one dashboard to another. +- [Issue #578](https://github.com/grafana/grafana/issues/578). Dashboard: Row option to display row title even when the row is visible +- [Issue #672](https://github.com/grafana/grafana/issues/672). Dashboard: panel fullscreen & edit state is present in url, can now link to graph in edit & fullscreen mode. +- [Issue #709](https://github.com/grafana/grafana/issues/709). Dashboard: Small UI look polish to search results, made dashboard title link are larger +- [Issue #425](https://github.com/grafana/grafana/issues/425). Graph: New section in 'Display Styles' tab to override any display setting on per series bases (mix and match lines, bars, points, fill, stack, line width etc) +- [Issue #634](https://github.com/grafana/grafana/issues/634). Dashboard: Dashboard tags now in different colors (from fixed palette) determined by tag name. +- [Issue #685](https://github.com/grafana/grafana/issues/685). Dashboard: New config.js option to change/remove window title prefix. +- [Issue #781](https://github.com/grafana/grafana/issues/781). Dashboard: Title URL is now slugified for greater URL readability, works with both ES & InfluxDB storage, is backward compatible +- [Issue #785](https://github.com/grafana/grafana/issues/785). Elasticsearch: Support for full elasticsearch lucene search grammar when searching for dashboards, better async search +- [Issue #787](https://github.com/grafana/grafana/issues/787). Dashboard: time range can now be read from URL parameters, will override dashboard saved time range + +**Fixes** + +- [Issue #696](https://github.com/grafana/grafana/issues/696). Graph: Fix for y-axis format 'none' when values are in scientific notation (ex 2.3e-13) +- [Issue #733](https://github.com/grafana/grafana/issues/733). Graph: Fix for tooltip current value decimal precision when 'none' axis format was selected +- [Issue #697](https://github.com/grafana/grafana/issues/697). Graphite: Fix for Glob syntax in graphite queries ([1-9] and ?) that made the query editor / parser bail and fallback to a text box. +- [Issue #702](https://github.com/grafana/grafana/issues/702). Graphite: Fix for nonNegativeDerivative function, now possible to not include optional first parameter maxValue +- [Issue #277](https://github.com/grafana/grafana/issues/277). Dashboard: Fix for timepicker date & tooltip when UTC timezone selected. +- [Issue #699](https://github.com/grafana/grafana/issues/699). Dashboard: Fix for bug when adding rows from dashboard settings dialog. +- [Issue #723](https://github.com/grafana/grafana/issues/723). Dashboard: Fix for hide controls setting not used/initialized on dashboard load +- [Issue #724](https://github.com/grafana/grafana/issues/724). Dashboard: Fix for zoom out causing right hand "to" range to be set in the future. + +**Tech** + +- Upgraded from angularjs 1.1.5 to 1.3 beta 17; +- Switch from underscore to lodash +- helpers to easily unit test angularjs controllers and services +- Test coverage through coveralls +- Upgrade from jquery 1.8.0 to 2.1.1 (**Removes support for IE7 & IE8**) + +# 1.7.1 (unreleased) + +**Fixes** + +- [Issue #691](https://github.com/grafana/grafana/issues/691). Dashboard: Tooltip fixes, sometimes they would not show, and sometimes they would get stuck. +- [Issue #695](https://github.com/grafana/grafana/issues/695). Dashboard: Tooltip on goto home menu icon would get stuck after clicking on it + +# 1.7.0 (2014-08-11) + +**Fixes** + +- [Issue #652](https://github.com/grafana/grafana/issues/652). Timepicker: Entering custom date range impossible when refresh is low (now is constantly reset) +- [Issue #450](https://github.com/grafana/grafana/issues/450). Graph: Tooltip does not disappear sometimes and would get stuck +- [Issue #655](https://github.com/grafana/grafana/issues/655). General: Auto refresh not initiated / started after dashboard loading +- [Issue #657](https://github.com/grafana/grafana/issues/657). General: Fix for refresh icon in IE browsers +- [Issue #661](https://github.com/grafana/grafana/issues/661). Annotations: Elasticsearch querystring with filter template replacements was not interpolated +- [Issue #660](https://github.com/grafana/grafana/issues/660). OpenTSDB: fix opentsdb queries that returned more than one series + +**Change** + +- [Issue #681](https://github.com/grafana/grafana/issues/681). Dashboard: The panel error bar has been replaced with a small error indicator, this indicator does not change panel height and is a lot less intrusive. Hover over it for short details, click on it for more details. + +# 1.7.0-rc1 (2014-08-05) + +**New features or improvements** + +- [Issue #581](https://github.com/grafana/grafana/issues/581). InfluxDB: Add continuous query in series results (series typeahead). +- [Issue #584](https://github.com/grafana/grafana/issues/584). InfluxDB: Support for alias & alias patterns when using raw query mode +- [Issue #394](https://github.com/grafana/grafana/issues/394). InfluxDB: Annotation support +- [Issue #633](https://github.com/grafana/grafana/issues/633). InfluxDB: InfluxDB can now act as a datastore for dashboards +- [Issue #610](https://github.com/grafana/grafana/issues/610). InfluxDB: Support for InfluxdB v0.8 list series response schema (series typeahead) +- [Issue #525](https://github.com/grafana/grafana/issues/525). InfluxDB: Enhanced series aliasing (legend names) with pattern replacements +- [Issue #266](https://github.com/grafana/grafana/issues/266). Graphite: New option cacheTimeout to override graphite default memcache timeout +- [Issue #606](https://github.com/grafana/grafana/issues/606). General: New global option in config.js to specify admin password (useful to hinder users from accidentally make changes) +- [Issue #201](https://github.com/grafana/grafana/issues/201). Annotations: Elasticsearch data source support for events +- [Issue #344](https://github.com/grafana/grafana/issues/344). Annotations: Annotations can now be fetched from non default data sources +- [Issue #631](https://github.com/grafana/grafana/issues/631). Search: max_results config.js option & scroll in search results (To show more or all dashboards) +- [Issue #511](https://github.com/grafana/grafana/issues/511). Text panel: Allow [[..]] filter notation in all text panels (markdown/html/text) +- [Issue #136](https://github.com/grafana/grafana/issues/136). Graph: New legend display option "Align as table" +- [Issue #556](https://github.com/grafana/grafana/issues/556). Graph: New legend display option "Right side", will show legend to the right of the graph +- [Issue #604](https://github.com/grafana/grafana/issues/604). Graph: New axis format, 'bps' (SI unit in steps of 1000) useful for network gear metrics +- [Issue #626](https://github.com/grafana/grafana/issues/626). Graph: Downscale y axis to more precise unit, value of 0.1 for seconds format will be formatted as 100 ms. Thanks @kamaradclimber +- [Issue #618](https://github.com/grafana/grafana/issues/618). OpenTSDB: Series alias option to override metric name returned from opentsdb. Thanks @heldr + +**Documentation** + +- [Issue #635](https://github.com/grafana/grafana/issues/635). Docs for features and changes in v1.7, new troubleshooting guide, new Getting started guide, improved install & config guide. + +**Changes** + +- [Issue #536](https://github.com/grafana/grafana/issues/536). Graphite: Use unix epoch for Graphite from/to for absolute time ranges +- [Issue #641](https://github.com/grafana/grafana/issues/536). General: Dashboard save temp copy feature settings moved from dashboard to config.js, default is enabled, and ttl to 30 days +- [Issue #532](https://github.com/grafana/grafana/issues/532). Schema: Dashboard schema changes, "Unsaved changes" should not appear for schema changes. All changes are backward compatible with old schema. + +**Fixes** + +- [Issue #545](https://github.com/grafana/grafana/issues/545). Graph: Fix formatting negative values (axis formats, legend values) +- [Issue #460](https://github.com/grafana/grafana/issues/460). Graph: fix for max legend value when max value is zero +- [Issue #628](https://github.com/grafana/grafana/issues/628). Filtering: Fix for nested filters, changing a child filter could result in infinite recursion in some cases +- [Issue #528](https://github.com/grafana/grafana/issues/528). Graphite: Fix for graphite expressions parser failure when metric expressions starts with curly brace segment + +# 1.6.1 (2014-06-24) + +**New features or improvements** + +- [Issue #360](https://github.com/grafana/grafana/issues/360). Ability to set y min/max for right y-axis (RR #519) + +**Fixes** + +- [Issue #500](https://github.com/grafana/grafana/issues/360). Fixes regex InfluxDB queries introduced in 1.6.0 +- [Issue #506](https://github.com/grafana/grafana/issues/506). Bug in when using % sign in legends (aliases), fixed by removing url decoding of metric names +- [Issue #522](https://github.com/grafana/grafana/issues/522). Series names and column name typeahead cache fix +- [Issue #504](https://github.com/grafana/grafana/issues/504). Fixed influxdb issue with raw query that caused wrong value column detection +- [Issue #526](https://github.com/grafana/grafana/issues/526). Default property that marks which data source is default in config.js is now optional +- [Issue #342](https://github.com/grafana/grafana/issues/342). Auto-refresh caused 2 refreshes (and hence multiple queries) each time (at least in firefox) + +# 1.6.0 (2014-06-16) + +#### New features or improvements + +- [Issue #427](https://github.com/grafana/grafana/issues/427). New Y-axis formater for metric values that represent seconds, Thanks @jippi +- [Issue #390](https://github.com/grafana/grafana/issues/390). Allow special characters in series names (influxdb data source), Thanks @majst01 +- [Issue #428](https://github.com/grafana/grafana/issues/428). Refactoring of filterSrv, Thanks @Tetha +- [Issue #445](https://github.com/grafana/grafana/issues/445). New config for playlist feature. Set playlist_timespan to set default playlist interval, Thanks @rmca +- [Issue #461](https://github.com/grafana/grafana/issues/461). New graphite function definition added isNonNull, Thanks @tmonk42 +- [Issue #455](https://github.com/grafana/grafana/issues/455). New InfluxDB function difference add to function dropdown +- [Issue #459](https://github.com/grafana/grafana/issues/459). Added parameter to keepLastValue graphite function definition (default 100) + [Issue #418](https://github.com/grafana/grafana/issues/418). to the browser cache when upgrading grafana and improve load performance +- [Issue #327](https://github.com/grafana/grafana/issues/327). Partial support for url encoded metrics when using Graphite data source. Thanks @axe-felix +- [Issue #473](https://github.com/grafana/grafana/issues/473). Improvement to InfluxDB query editor and function/value column selection +- [Issue #375](https://github.com/grafana/grafana/issues/375). Initial support for filtering (templated queries) for InfluxDB. Thanks @mavimo +- [Issue #475](https://github.com/grafana/grafana/issues/475). Row editing and adding new panel is now a lot quicker and easier with the new row menu +- [Issue #211](https://github.com/grafana/grafana/issues/211). New data source! Initial support for OpenTSDB, Thanks @mpage +- [Issue #492](https://github.com/grafana/grafana/issues/492). Improvement and polish to the OpenTSDB query editor +- [Issue #441](https://github.com/grafana/grafana/issues/441). Influxdb group by support, Thanks @piis3 +- improved asset (css/js) build pipeline, added revision to css and js. Will remove issues related + +#### Changes + +- [Issue #475](https://github.com/grafana/grafana/issues/475). Add panel icon and Row edit button is replaced by the Row edit menu +- New graphs now have a default empty query +- Add Row button now creates a row with default height of 250px (no longer opens dashboard settings modal) +- Clean up of config.sample.js, graphiteUrl removed (still works, but deprecated, removed in future) + Use data sources config instead. panel_names removed from config.js. Use plugins.panels to add custom panels +- Graphite panel is now renamed graph (Existing dashboards will still work) + +#### Fixes + +- [Issue #126](https://github.com/grafana/grafana/issues/126). Graphite query lexer change, can now handle regex parameters for aliasSub function +- [Issue #447](https://github.com/grafana/grafana/issues/447). Filter option loading when having multiple nested filters now works better. Options are now reloaded correctly and there are no multiple renders/refresh in between. +- [Issue #412](https://github.com/grafana/grafana/issues/412). After a filter option is changed and a nested template param is reloaded, if the current value exists after the options are reloaded the current selected value is kept. +- [Issue #460](https://github.com/grafana/grafana/issues/460). Legend Current value did not display when value was zero +- [Issue #328](https://github.com/grafana/grafana/issues/328). Fix to series toggling bug that caused annotations to be hidden when toggling/hiding series. +- [Issue #293](https://github.com/grafana/grafana/issues/293). Fix for graphite function selection menu that some times draws outside screen. It now displays upward +- [Issue #350](https://github.com/grafana/grafana/issues/350). Fix for exclusive series toggling (hold down CTRL, SHIFT or META key) and left click a series for exclusive toggling +- [Issue #472](https://github.com/grafana/grafana/issues/472). CTRL does not work on MAC OSX but SHIFT or META should (depending on browser) + +# 1.5.4 (2014-05-13) + +### New features and improvements + +- InfluxDB enhancement: support for multiple hosts (with retries) and raw queries ([Issue #318](https://github.com/grafana/grafana/issues/318), thx @toddboom) +- Added rounding for graphites from and to time range filters + for very short absolute ranges ([Issue #320](https://github.com/grafana/grafana/issues/320)) +- Increased resolution for graphite datapoints (maxDataPoints), now equal to panel pixel width. ([Issue #5](https://github.com/grafana/grafana/issues/5)) +- Improvement to influxdb query editor, can now add where clause and alias ([Issue #331](https://github.com/grafana/grafana/issues/331), thanks @mavimo) +- New config setting for graphite data source to control if json render request is POST or GET ([Issue #345](https://github.com/grafana/grafana/issues/345)) +- Unsaved changes warning feature ([Issue #324](https://github.com/grafana/grafana/issues/324)) +- Improvement to series toggling, CTRL+MouseClick on series name will now hide all others ([Issue #350](https://github.com/grafana/grafana/issues/350)) + +### Changes + +- Graph default setting for Y-Min changed from zero to auto scaling (will not effect existing dashboards). ([Issue #386](https://github.com/grafana/grafana/issues/386)) - thx @kamaradclimber + +### Fixes + +- Fixes to filters and "All" option. It now never uses "\*" as value, but all options in a {node1, node2, node3} expression ([Issue #228](https://github.com/grafana/grafana/issues/228), #359) +- Fix for InfluxDB query generation with columns containing dots or dashes ([Issue #369](https://github.com/grafana/grafana/issues/369), #348) - Thanks to @jbripley + +# 1.5.3 (2014-04-17) + +- Add support for async scripted dashboards ([Issue #274](https://github.com/grafana/grafana/issues/274)) +- Text panel now accepts html (for links to other dashboards, etc) ([Issue #236](https://github.com/grafana/grafana/issues/236)) +- Fix for Text panel, now changes take effect directly ([Issue #251](https://github.com/grafana/grafana/issues/251)) +- Fix when adding functions without params that did not cause graph to update ([Issue #267](https://github.com/grafana/grafana/issues/267)) +- Graphite errors are now much easier to see and troubleshoot with the new inspector ([Issue #265](https://github.com/grafana/grafana/issues/265)) +- Use influxdb aliases to distinguish between multiple columns ([Issue #283](https://github.com/grafana/grafana/issues/283)) +- Correction to ms axis formater, now formats days correctly. ([Issue #189](https://github.com/grafana/grafana/issues/189)) +- Css fix for Firefox and using top menu dropdowns in panel fullscreen / edit mode ([Issue #106](https://github.com/grafana/grafana/issues/106)) +- Browser page title is now Grafana - {{dashboard title}} ([Issue #294](https://github.com/grafana/grafana/issues/294)) +- Disable auto refresh zooming in (every time you change to an absolute time range), refresh will be restored when you change time range back to relative ([Issue #282](https://github.com/grafana/grafana/issues/282)) +- More graphite functions + +# 1.5.2 (2014-03-24) + +### New Features and improvements + +- Support for second optional params for functions like aliasByNode ([Issue #167](https://github.com/grafana/grafana/issues/167)). Read the wiki on the [Function Editor](https://github.com/torkelo/grafana/wiki/Graphite-Function-Editor) for more info. +- More functions added to InfluxDB query editor ([Issue #218](https://github.com/grafana/grafana/issues/218)) +- Filters can now be used inside other filters (templated segments) ([Issue #128](https://github.com/grafana/grafana/issues/128)) +- More graphite functions added + +### Fixes + +- Float arguments now work for functions like scale ([Issue #223](https://github.com/grafana/grafana/issues/223)) +- Fix for graphite function editor, the graph & target was not updated after adding a function and leaving default params as is #191 + +The zip files now contains a sub folder with project name and version prefix. ([Issue #209](https://github.com/grafana/grafana/issues/209)) + +# 1.5.1 (2014-03-10) + +### Fixes + +- maxDataPoints must be an integer #184 (thanks @frejsoya for fixing this) + +For people who are find Grafana slow for large time spans or high resolution metrics. This is most likely due to graphite returning a large number of datapoints. The maxDataPoints parameter solves this issue. For maxDataPoints to work you need to run the latest graphite-web (some builds of 0.9.12 does not include this feature). + +Read this for more info: +[Performance for large time spans](https://github.com/torkelo/grafana/wiki/Performance-for-large-time-spans) + +# 1.5.0 (2014-03-09) + +### New Features and improvements + +- New function editor [video demo](http://youtu.be/I90WHRwE1ZM) ([Issue #178](https://github.com/grafana/grafana/issues/178)) +- Links to function documentation from function editor ([Issue #3](https://github.com/grafana/grafana/issues/3)) +- Reorder functions ([Issue #130](https://github.com/grafana/grafana/issues/130)) +- [Initial support for InfluxDB](https://github.com/torkelo/grafana/wiki/InfluxDB) as metric data source (#103), need feedback! +- [Dashboard playlist](https://github.com/torkelo/grafana/wiki/Dashboard-playlist) ([Issue #36](https://github.com/grafana/grafana/issues/36)) +- When adding aliasByNode smartly set node number ([Issue #175](https://github.com/grafana/grafana/issues/175)) +- Support graphite identifiers with embedded colons ([Issue #173](https://github.com/grafana/grafana/issues/173)) +- Typeahead & autocomplete when adding new function ([Issue #164](https://github.com/grafana/grafana/issues/164)) +- More graphite function definitions +- Make "ms" axis format include hour, day, weeks, month and year ([Issue #149](https://github.com/grafana/grafana/issues/149)) +- Microsecond axis format ([Issue #146](https://github.com/grafana/grafana/issues/146)) +- Specify template parameters in URL ([Issue #123](https://github.com/grafana/grafana/issues/123)) + +### Fixes + +- Basic Auth fix ([Issue #152](https://github.com/grafana/grafana/issues/152)) +- Fix to annotations with graphite source & null values ([Issue #138](https://github.com/grafana/grafana/issues/138)) + +# 1.4.0 (2014-02-21) + +### New Features + +- #44 Annotations! Required a lot of work to get right. Read wiki article for more info. Supported annotations data sources are graphite metrics and graphite events. Support for more will be added in the future! +- #35 Support for multiple graphite servers! (Read wiki article for more) +- #116 Back to dashboard link in top menu to easily exist full screen / edit mode. +- #114, #97 Legend values now use the same y axes formatter +- #77 Improvements and polish to the light theme + +### Changes + +- #98 Stack is no longer by default turned on in graph display settings. +- Hide controls (Ctrl+h) now hides the sub menu row (where filtering, and annotations are). So if you had filtering enabled and hide controls enabled you will not see the filtering sub menu. + +### Fixes: + +- #94 Fix for bug that caused dashboard settings to sometimes not contain timepicker tab. +- #110 Graph with many many metrics caused legend to push down graph editor below screen. You can now scroll in edit mode & full screen mode for graphs with lots of series & legends. +- #104 Improvement to graphite target editor, select wildcard now gives you a "select metric" link for the next node. +- #105 Added zero as a possible node value in groupByAlias function + +# 1.3.0 (2014-02-13) + +### New features or improvements + +- #86 Dashboard tags and search (see wiki article for details) +- #54 Enhancement to filter / template. "Include All" improvement +- #82 Dashboard search result sorted in alphabetical order + +### Fixes + +- #91 Custom date selector is one day behind +- #89 Filter / template does not work after switching dashboard +- #88 Closed / Minimized row css bug +- #85 Added all parameters to summarize function +- #83 Stack as percent should now work a lot better! + +# 1.2.0 (2014-02-10) + +### New features + +- #70 Grid Thresholds (warning and error regions or lines in graph) +- #72 Added an example of a scripted dashboard and a short wiki article documenting scripted dashboards. + +### Fixes + +- #81 Grid min/max values are ignored bug +- #80 "stacked as percent" graphs should always use "max" value of 100 bug +- #73 Left Y format change did not work +- #42 Fixes to grid min/max auto scaling +- #69 Fixes to lexer/parser for metrics segments like "10-20". +- #67 Allow decimal input for scale function +- #68 Bug when trying to open dashboard while in edit mode + +# 1.1.0 (2014-02-06) + +### New features: + +- #22 Support for native graphite png renderer, does not support click and select zoom yet +- #60 Support for legend values (cactiStyle, min, max, current, total, avg). The options for these are found in the new "Axes & Grid" tab for now. +- #62 There is now a "New" button in the search/open dashboard view to quickly open a clean empty dashboard. +- #55 Basic auth is now supported for elastic search as well +- some new function definitions added (will focus more on this for next release). + +### Fixes + +- #45 zero values from graphite was handled as null. +- #63 Kibana / Grafana on same host would use same localStorage keys, now fixed +- #46 Impossible to edit graph without a name fixed. +- #24 fix for dashboard search when elastic search is configured to disable \_all field. +- #38 Improvement to lexer / parser to support pure numeric literals in metric segments + +Thanks to everyone who contributed fixes and provided feedback :+1: + +# 1.0.4 (2014-01-24) + +- [Issue #28](https://github.com/grafana/grafana/issues/28) - Relative time range caused 500 graphite error in some cases (thx rsommer for the fix) + +# 1.0.3 (2014-01-23) + +- #9 Add Y-axis format for milliseconds +- #16 Add support for Basic Auth (use http://username:password@yourgraphitedomain.com) +- #13 Relative time ranges now uses relative time ranges when issuing graphite query + +# 1.0.2 (2014-01-21) + +- [Issue #12](https://github.com/grafana/grafana/issues/12), should now work ok without ElasticSearch + +# 1.0.1 (2014-01-21) + +- Resize fix +- Improvements to drag & drop +- Added a few graphite function definitions +- Fixed duplicate panel bug +- Updated default dashboard with welcome message and randomWalk graph + +# 1.0.0 (2014-01-19) + +First public release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3d4caa4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at conduct@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..135d273 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to Grafana + +Thank you for your interest in contributing to Grafana! We welcome all people who want to contribute in a healthy and constructive manner within our community. To help us create a safe and positive community experience for all, we require all participants to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). + +This document is a guide to help you through the process of contributing to Grafana. + +## Become a contributor + +You can contribute to Grafana in several ways. Here are some examples: + +- Contribute to the Grafana codebase. +- Report and triage bugs. +- Develop community plugins and dashboards. +- Write technical documentation and blog posts, for users and contributors. +- Organize meetups and user groups in your local area. +- Help others by answering questions about Grafana. + +For more ways to contribute, check out the [Open Source Guides](https://opensource.guide/how-to-contribute/). + +### Report bugs + +Before submitting a new issue, try to make sure someone hasn't already reported the problem. Look through the [existing issues](https://github.com/grafana/grafana/issues) for similar issues. + +Report a bug by submitting a [bug report](https://github.com/grafana/grafana/issues/new?labels=type%3A+bug&template=1-bug_report.md). Make sure that you provide as much information as possible on how to reproduce the bug. + +Follow the issue template and add additional information that will help us replicate the problem. + +For data visualization issues: +- Query results from the inspect drawer (data tab & query inspector) +- Panel settings can be extracted in the panel inspect drawer JSON tab + +For a dashboard related issues: +- Dashboard JSON can be found in the dashboard settings JSON model view + +For authentication and alerting Grafana server logs are useful. + +#### Security issues + +If you believe you've found a security vulnerability, please read our [security policy](https://github.com/grafana/grafana/security/policy) for more details. + +### Suggest enhancements + +If you have an idea of how to improve Grafana, submit an [enhancement request](https://github.com/grafana/grafana/issues/new?labels=type%3A+feature+request&template=2-feature_request.md). + +We want to make Grafana accessible to even more people. Submit an [accessibility issue](https://github.com/grafana/grafana/issues/new?labels=type%3A+accessibility&template=3-accessibility.md) to help us understand what we can improve. + +### Triage issues + +If you don't have the knowledge or time to code, consider helping with _issue triage_. The community will thank you for saving them time by spending some of yours. + +Read more about the ways you can [Triage issues](/contribute/triage-issues.md). + +### Answering questions + +If you have a question and you can't find the answer in the [documentation](https://grafana.com/docs/), the next step is to ask it on the [community site](https://community.grafana.com/). + +It's important to us to help these users, and we'd love your help. Sign up to our [community site](https://community.grafana.com/), and start helping other Grafana users by answering their questions. + +### Your first contribution + +Unsure where to begin contributing to Grafana? Start by browsing issues labeled `beginner friendly` or `help wanted`. + +- [Beginner-friendly](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) issues are generally straightforward to complete. +- [Help wanted](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) issues are problems we would like the community to help us with regardless of complexity. + +If you're looking to make a code change, see how to set up your environment for [local development](contribute/developer-guide.md). + +When you're ready to contribute, it's time to [Create a pull request](/contribute/create-pull-request.md). + +#### Contributor License Agreement (CLA) + +Before we can accept your pull request, you need to [sign our CLA](https://grafana.com/docs/grafana/latest/developers/cla/). If you haven't, our CLA assistant prompts you to when you create your pull request. + +## Where do I go from here? + +- Set up your [development environment](contribute/developer-guide.md). +- Learn how to [contribute documentation](contribute/documentation.md). +- Get started [developing plugins](https://grafana.com/docs/grafana/latest/developers/plugins/) for Grafana. +- Look through the resources in the [contribute](https://github.com/grafana/grafana/tree/main/contribute) folder. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ab5f09f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,88 @@ +FROM node:14.16.0-alpine3.13 as js-builder + +WORKDIR /usr/src/app/ + +COPY package.json yarn.lock ./ +COPY packages packages + +RUN apk --no-cache add git +RUN yarn install --pure-lockfile --no-progress + +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ +COPY public public +COPY tools tools +COPY scripts scripts +COPY emails emails + +ENV NODE_ENV production +RUN yarn build + +FROM golang:1.16.1-alpine3.13 as go-builder + +RUN apk add --no-cache gcc g++ + +WORKDIR $GOPATH/src/github.com/grafana/grafana + +COPY go.mod go.sum embed.go ./ + +RUN go mod verify + +COPY cue cue +COPY public/app/plugins public/app/plugins +COPY pkg pkg +COPY build.go package.json ./ + +RUN go run build.go build + +# Final stage +FROM alpine:3.13 + +LABEL maintainer="Grafana team " + +ARG GF_UID="472" +ARG GF_GID="0" + +ENV PATH="/usr/share/grafana/bin:$PATH" \ + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + +WORKDIR $GF_PATHS_HOME + +RUN apk add --no-cache ca-certificates bash tzdata && \ + apk add --no-cache openssl musl-utils + +COPY conf ./conf + +RUN if [ ! $(getent group "$GF_GID") ]; then \ + addgroup -S -g $GF_GID grafana; \ + fi + +RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ + mkdir -p "$GF_PATHS_HOME/.aws" && \ + adduser -S -u $GF_UID -G "$GF_GID_NAME" grafana && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ + cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ + chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ + chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" + +COPY --from=go-builder /go/src/github.com/grafana/grafana/bin/*/grafana-server /go/src/github.com/grafana/grafana/bin/*/grafana-cli ./bin/ +COPY --from=js-builder /usr/src/app/public ./public +COPY --from=js-builder /usr/src/app/tools ./tools + +EXPOSE 3000 + +COPY ./packaging/docker/run.sh /run.sh + +USER grafana +ENTRYPOINT [ "/run.sh" ] diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu new file mode 100644 index 0000000..1d0b6ed --- /dev/null +++ b/Dockerfile.ubuntu @@ -0,0 +1,80 @@ +FROM node:14.15.1-slim AS js-builder + +WORKDIR /usr/src/app/ + +COPY package.json yarn.lock ./ +COPY packages packages + +RUN apt-get update && apt-get install -yq git +RUN yarn install --pure-lockfile + +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ +COPY public public +COPY tools tools +COPY scripts scripts +COPY emails emails + +ENV NODE_ENV production +RUN yarn build + +FROM golang:1.16 AS go-builder + +WORKDIR /src/grafana + +COPY go.mod go.sum embed.go ./ + +RUN go mod verify + +COPY build.go package.json ./ +COPY pkg pkg/ +COPY cue cue/ +COPY public/app/plugins public/app/plugins/ + +RUN go run build.go build + +FROM ubuntu:20.04 + +LABEL maintainer="Grafana team " +EXPOSE 3000 + +ARG GF_UID="472" +ARG GF_GID="472" + +ENV PATH="/usr/share/grafana/bin:$PATH" \ + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + +WORKDIR $GF_PATHS_HOME + +COPY conf conf + +# curl should be part of the image +RUN apt-get update && apt-get install -y ca-certificates curl + +RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ + addgroup --system --gid $GF_GID grafana && \ + adduser --uid $GF_UID --system --ingroup grafana grafana && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp conf/sample.ini "$GF_PATHS_CONFIG" && \ + cp conf/ldap.toml /etc/grafana/ldap.toml && \ + chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ + chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" + +COPY --from=go-builder /src/grafana/bin/*/grafana-server /src/grafana/bin/*/grafana-cli bin/ +COPY --from=js-builder /usr/src/app/public public +COPY --from=js-builder /usr/src/app/tools tools + +COPY packaging/docker/run.sh / + +USER grafana +ENTRYPOINT [ "/run.sh" ] diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..11c3038 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,205 @@ +# Governance + +This document describes the rules and governance of the project. It is meant to be followed by all the developers of the project and the Grafana community. Common terminology used in this governance document are listed below: + +- **Team members**: Any members of the private [grafana-team][team] Google group. + +- **Maintainers**: Maintainers lead an individual project or parts thereof ([`MAINTAINERS.md`][maintainers]). + +- **Projects**: A single repository in the Grafana GitHub organization and listed below is referred to as a project: + + - clock-panel + - devtools + - gel-app + - grafana + - grafana-github-datasource + - grafana-image-renderer + - grafana-kiosk + - grafana-plugin-sdk-go + - grafana-polystat-panel + - grafonnet-lib + - kairosdb-datasource + - piechart-panel + - simple-angular-panel + - simple-app-plugin + - simple-datasource + - simple-datasource-backend + - simple-json-backend-datasource + - simple-json-datasource + - simple-react-panel + - strava-datasource + - tutorials + - worldmap-panel + +- **The Grafana project**: The sum of all activities performed under this governance, concerning one or more repositories or the community. + +## Values + +The Grafana developers and community are expected to follow the values defined in the Grafana Code of Conduct. Furthermore, the Grafana community strives for kindness, giving feedback effectively, and building a welcoming environment. The Grafana developers generally decide by consensus and only resort to conflict resolution by a majority vote if consensus cannot be reached. + +## Projects + +Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release process, access and documentation should be such that more than one person can perform a release. Releases should be announced on the Grafana Labs blog. Any new projects should be first proposed on the [team mailing list][team] following the voting procedures listed below. + +## Decision making + +### Team members + +Team member status may be given to those who have made ongoing contributions to the Grafana project for at least 3 months. This is usually in the form of code improvements and/or notable work on documentation, but organizing events or user support could also be taken into account. + +New members may be proposed by any existing member by email to [grafana-team][team]. It is highly desirable to reach consensus about acceptance of a new member. However, the proposal is ultimately voted on by a formal [supermajority vote](#supermajority-vote). + +If the new member proposal is accepted, the proposed team member should be contacted privately via email to confirm or deny their acceptance of team membership. This email will also be CC'd to [grafana-team][team] for record-keeping purposes. + +If they choose to accept, the [onboarding](#onboarding) procedure is followed. + +Team members may retire at any time by emailing [the team][team]. + +Team members can be removed by [supermajority vote](#supermajority-vote) on [the team mailing list][team]. +For this vote, the member in question is not eligible to vote and does not count towards the quorum. +Any removal vote can cover only one single person. + +Upon death of a member, they leave the team automatically. + +In case a member leaves, the [offboarding](#offboarding) procedure is applied. + +The current team members are: + +- Alexander Zobnin ([Grafana Labs](https://grafana.com/)) +- Alex Khomenko ([Grafana Labs](https://grafana.com/)) +- Andrej Ocenas ([Grafana Labs](https://grafana.com/)) +- Arve Knudsen ([Grafana Labs](https://grafana.com/)) +- Brian Gann ([Grafana Labs](https://grafana.com/)) +- Carl Bergquist ([Grafana Labs](https://grafana.com/)) +- Chris Trott ([Grafana Labs](https://grafana.com/)) +- Daniel Lee ([Grafana Labs](https://grafana.com/)) +- David Kaltschmidt ([Grafana Labs](https://grafana.com/)) +- Diana Payton ([Grafana Labs](https://grafana.com/)) +- Diana Sarlinska ([Grafana Labs](https://grafana.com/)) +- Dominik Prokop ([Grafana Labs](https://grafana.com/)) +- Emil Tullstedt ([Grafana Labs](https://grafana.com/)) +- Fredrik Enestad ([Soundtrack Your Brand](https://www.soundtrackyourbrand.com/)) +- Hugo Häggmark ([Grafana Labs](https://grafana.com/)) +- Ivana Huckova ([Grafana Labs](https://grafana.com/)) +- Jeroen Op 't Eynde ([Grafana Labs](https://grafana.com/)) +- Jessica Müller ([Grafana Labs](https://grafana.com/)) +- Julien Pivotto ([Inuits](https://inuits.eu/)) +- Kay Delaney ([Grafana Labs](https://grafana.com/)) +- Kyle Brandt ([Grafana Labs](https://grafana.com/)) +- Leonard Gram ([Grafana Labs](https://grafana.com/)) +- Lukas Siatka ([Grafana Labs](https://grafana.com/)) +- Malcolm Holmes ([Grafana Labs](https://grafana.com/)) +- Marcus Andersson ([Grafana Labs](https://grafana.com/)) +- Marcus Efraimsson ([Grafana Labs](https://grafana.com/)) +- Marcus Olsson ([Grafana Labs](https://grafana.com/)) +- Mitsuhiro Tanda ([GREE](https://corp.gree.net/jp/en/)) +- Patrick O’Carroll ([Grafana Labs](https://grafana.com/)) +- Peter Holmberg ([Grafana Labs](https://grafana.com/)) +- Richard Hartmann ([Grafana Labs](https://grafana.com/)) +- Ryan McKinley ([Grafana Labs](https://grafana.com/)) +- Sofia Papagiannaki ([Grafana Labs](https://grafana.com/)) +- Stephanie Closson ([Grafana Labs](https://grafana.com/)) +- Tobias Skarhed ([Grafana Labs](https://grafana.com/)) +- Torkel Ödegaard ([Grafana Labs](https://grafana.com/)) +- Utkarsh Bhatnagar ([Tinder](https://www.tinder.com/)) + +### Maintainers + +Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable maintainers that, for whatever reason, are not yet team members. + +Changes in maintainership have to be announced on the [developers mailing list][devs]. They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] file of the respective repository. + +Maintainers are granted commit rights to all projects covered by this governance. + +A maintainer or committer may resign by notifying the [team mailing list][team]. A maintainer with no project activity for a year is considered to have resigned. Maintainers that wish to resign are encouraged to propose another team member to take over the project. + +A project may have multiple maintainers, as long as the responsibilities are clearly agreed upon between them. This includes coordinating who handles which issues and pull requests. + +### Technical decisions + +Technical decisions that only affect a single project are made informally by the maintainer of this project, and [rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the Grafana project should be discussed and made on the [Grafana developer mailing list][devs]. + +Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved by [majority vote](#majority-vote). + +### Governance changes + +Changes to this document are made by Grafana Labs. + +### Other matters + +Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise on the [developer mailing list][devs]. + +## Voting + +The Grafana project usually runs by informal consensus, however sometimes a formal decision must be made. + +Depending on the subject matter, as laid out [above](#decision-making), different methods of voting are used. + +For all votes, voting must be open for at least one week. The end date should be clearly stated in the call to vote. A vote may be called and closed early if enough votes have come in one way so that further votes cannot change the final decision. + +In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced removal of a team member, in which said member is not eligible to vote. + +Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in private on the [team mailing list][team]. All other discussion and votes are held in public on the [developer mailing list][devs]. + +For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to [team members](#team-members). + +### Consensus + +The default decision making mechanism for the Grafana project is [rough][rough] consensus. This means that any decision on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been considered but not necessarily accommodated. + +Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may be stated at will. Decisions may, but do not need to be called out and put up for decision on the [developers mailing list][devs] at any time and by anyone. + +Consensus decisions can never override or go against the spirit of an earlier explicit vote. + +If any [team member](#team-members) raises objections, the team members work together towards a solution that all involved can accept. This solution is again subject to rough consensus. + +In case no consensus can be found, but a decision one way or the other must be made, any [team member](#team-members) may call a formal [majority vote](#majority-vote). + +### Majority vote + +Majority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. + +Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. + +A vote on a single proposal is considered successful if more vote in favor than against. + +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. It is not possible to cast an “abstain” vote. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from more than half of those voting. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. + +### Supermajority vote + +Supermajority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. + +Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. + +A vote on a single proposal is considered successful if at least two thirds of those eligible to vote vote in favor. + +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. + +## On- / Offboarding + +### Onboarding + +The new member is + +- added to the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. +- announced on the [developers mailing list][devs] by an existing team member. Ideally, the new member replies in this thread, acknowledging team membership. +- added to the projects with commit rights. +- added to the [team mailing list][team]. + +### Offboarding + +The ex-member is + +- removed from the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. In case of forced removal, no approval is needed. +- removed from the projects. Optionally, they can retain maintainership of one or more repositories if the [team](#team-members) agrees. +- removed from the team mailing list and demoted to a normal member of the other mailing lists. +- not allowed to call themselves an active team member any more, nor allowed to imply this to be the case. +- added to a list of previous members if they so choose. + +If needed, we reserve the right to publicly announce removal. + +[coc]: https://github.com/grafana/grafana/blob/main/CODE_OF_CONDUCT.md +[devs]: https://groups.google.com/forum/#!forum/grafana-developers +[maintainers]: https://github.com/grafana/grafana/blob/main/MAINTAINERS.md +[rough]: https://tools.ietf.org/html/rfc7282 +[team]: https://groups.google.com/forum/#!forum/grafana-team diff --git a/ISSUE_TRIAGE.md b/ISSUE_TRIAGE.md new file mode 100644 index 0000000..99dbcf9 --- /dev/null +++ b/ISSUE_TRIAGE.md @@ -0,0 +1,348 @@ +# Triage issues + +The main goal of issue triage is to categorize all incoming Grafana issues and make sure each issue has all basic information needed for anyone else to understand and be able to start working on it. + +> **Note:** This information is for Grafana project Maintainers, Owners, and Admins. If you are a Contributor, then you will not be able to perform most of the tasks in this topic. + +The core maintainers of the Grafana project are responsible for categorizing all incoming issues and delegating any critical or important issue to other maintainers. Currently one maintainer each week is responsible. Besides that part, triage provides an important way to contribute to an open source project. + +Triage helps ensure issues resolve quickly by: + +- Ensuring the issue's intent and purpose is conveyed precisely. This is necessary because it can be difficult for an issue to explain how an end user experiences a problem and what actions they took. +- Giving a contributor the information they need before they commit to resolving an issue. +- Lowering the issue count by preventing duplicate issues. +- Streamlining the development process by preventing duplicate discussions. + +If you don't have the knowledge or time to code, consider helping with triage. The community will thank you for saving them time by spending some of yours. + +## Simplified flowchart diagram of the issue triage process + + +``` + +--------------------------+ + +----------------+ New issue opened/ | + | | more information added | + | +-------------+------------+ + | Ask for more | + | information +-------------+------------+ + | | All information needed | + | +--------+ to categorize the issue? +--------+ + | | | | | + | | NO +--------------------------+ YES | + | | | ++------+-------+-------------+ +------------+---------+ +----------------------------+ +| | | | | | +| label: needs more info | | Needs investigation? +--YES---+ label: needs investigation | +| | | | | | ++----------------------------+ +----------------+-----+ +--------------+-------------+ + NO | | + | Investigate | + +-----------+----------+ | + | label: type/* | | + | label: area/* +------------------+ + | label: datasource/* | + +-----|----------+-----+ + | | + | | + | +--+--------------------+ +--------------------+ + | | | | label: priority/* | + | | Needs priority? +--YES---+| milestone? | + | | | | | + | +--------------------+--+ +----+---------------+ + | NO | | + | | | + +----+-------------+ +---+----------+ | + | | | | | + | Close issue +----------+ Done +------+ + | | | | + +------------------+ +--------------+ +``` + +## 1. Find uncategorized issues + +To get started with issue triage and finding issues that haven't been triaged you have two alternatives. + +### Browse unlabeled issues + +The easiest and straight forward way of getting started and finding issues that haven't been triaged is to browse [unlabeled issues](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+no%3Alabel) and starting from the bottom and working yourself to the top. + +### Subscribe to all notifications + +The more advanced, but recommended way is to subscribe to all notifications from this repository which means that all new issues, pull requests, comments and important status changes are sent to your configured email address. Read this [guide](https://help.github.com/en/articles/watching-and-unwatching-repositories#watching-a-single-repository) for help with setting this up. + +It's highly recommended that you setup filters to automatically remove emails from the inbox and label/categorize them accordingly to make it easy for you to understand when you need to act upon a notification or where to look for finding issues that haven't been triaged etc. + +Instructions for setting up filters in Gmail can be found [here](#setting-up-gmail-filters). Another alternative is to use [Trailer](https://github.com/ptsochantaris/trailer) or similar software. + +## 2. Ensure the issue contains basic information + +Before triaging an issue very far, make sure that the issue's author provided the standard issue information. This will help you make an educated recommendation on how to categorize the issue. The Grafana project utilizes [GitHub issue templates](https://help.github.com/en/articles/creating-issue-templates-for-your-repository) to guide contributors to provide standard information that must be included for each type of template or type of issue. + +### Standard issue information that must be included + +Given a certain [issue template]([template](https://github.com/grafana/grafana/issues/new/choose)) have been used by the issue author or depending how the issue is perceived by the issue triage responsible, the following should help you understand what standard issue information that must be included. + +#### Bug reports + +Should explain what happened, what was expected and how to reproduce it together with any additional information that may help giving a complete picture of what happened such as screenshots, [query inspector](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630) output and any environment related information that's applicable and/or maybe related to the reported problem: +- Grafana version +- Data source type & version +- Platform & OS Grafana is installed on +- User OS & Browser + versions +- Using docker + what environment +- Which plugins +- Configuration database in use (sqlite, mysql, postgres) +- Reverse proxy in front of Grafana, what version and configuration +- Non-default configuration settings +- Development environment like Go and Node versions, if applicable + +#### Enhancement requests + +Should explain what enhancement or feature that the author wants to be added and why that is needed. + +#### Accessibility issues + +This is a mix between a bug report and enhancement request but focused on accessibility issues to help make Grafana improve keyboard navigation, screen-reader support and being accessible to everyone. The report should include relevant WCAG criteria, if applicable. + +#### Support requests + +In general, if the issue description and title is perceived as a question no more information is needed. + +### Good practices + +To make it easier for everyone to understand and find issues they're searching for it's suggested as a general rule of thumbs to: + +- Make sure that issue titles are named to explain the subject of the issue, has a correct spelling and doesn't include irrelevant information and/or sensitive information. +- Make sure that issue descriptions doesn't include irrelevant information, information from template that haven't been filled out and/or sensitive information. +- Do your best effort to change title and description or request suggested changes by adding a comment. + +> **Note:** Above rules is applicable to both new and existing issues of the Grafana project. + +### Do you have all the information needed to categorize an issue? + +Depending on the issue, you might not feel all this information is needed. Use your best judgement. If you cannot triage an issue using what its author provided, explain kindly to the author that they must provide the above information to clarify the problem. Label issue with `needs more detail` and add any related `area/*` or `datasource/*` labels. + +If the author provides the standard information but you are still unable to triage the issue, request additional information. Do this kindly and politely because you are asking for more of the author's time. + +If the author does not respond to the requested information within the timespan of a week, close the issue with a kind note stating that the author can request for the issue to be reopened when the necessary information is provided. + +When you feel you have all the information needed you're ready to [categorizing the issue](#3-categorizing-an-issue). + +If you receive a notification with additional information provided but you are not anymore on issue triage and you feel you do not have time to handle it, you should delegate it to the current person on issue triage. + +## 3. Categorizing an issue + +An issue can have multiple of the following labels. Typically, a properly categorized issue should at least have: + +- One label identifying its type (`type/*`). +- One or multiple labels identifying the functional areas of interest or component (`area/*`) and/or data source (`datasource/*`), if applicable. + +| Label | Description | +| ------------------------ | ------------------------------------------------------------------------- | +| `type/bug` | A feature isn't working as expected given design or documentation. | +| `type/feature-request` | Request for a new feature or enhancement. | +| `type/docs` | Documentation problem or enhancement. | +| `type/accessibility` | Accessibility problem or enhancement. | +| `type/question` | Issue is a question or is perceived as such. | +| `type/duplicate` | An existing issue of the same subject/request have already been reported. | +| `type/works-as-intended` | A reported bug works as intended/by design. | +| `type/build-packaging` | Build or packaging problem or enhancement. | +| `area/*` | Subject is related to a functional area of interest or component. | +| `datasource/*` | Subject is related to a core data source plugin. | + +### Duplicate issues + +Make sure it's not a duplicate by searching existing issues using related terms from the issue title and description. If you think you know there is an existing issue, but can't find it, please reach out to one of the maintainers and ask for help. If you identify that the issue is a duplicate of an existing issue: + +1. Add a comment `/duplicate of #`. GitHub will recognize this and add some additional context to the issue activity. +2. The Grafana bot will do the rest, adding the correct label and closing comment +3. Optionally add any related `area/*` or `datasource/*` labels. + +### Bug reports + +If it's not perfectly clear that it's an actual bug, quickly try to reproduce it. + +**It's a bug/it can be reproduced:** + +1. Add a comment describing detailed steps for how to reproduce it, if applicable. +2. Label the issue `type/bug` and at least one `area/*` or `datasource/*` label. +3. If you know that maintainers wont be able to put any resources into it for some time then label the issue with `help wanted` and optionally `beginner friendly` together with pointers on which code to update to fix the bug. This should signal to the community that we would appreciate any help we can get to resolve this. +4. Move on to [prioritizing the issue](#4-prioritization-of-issues). + +**It can't be reproduced:** +1. Either [ask for more information](#2-ensure-the-issue-contains-basic-information) needed to investigate it more thoroughly. +2. Either [delegate further investigations](#investigation-of-issues) to someone else. + +**It works as intended/by design:** +1. Kindly and politely add a comment explaining briefly why we think it works as intended and close the issue. +2. Label the issue `type/works-as-intended`. + +### Enhancement/feature? + +1. Label the issue `type/feature-request` and at least one `area/*` or `datasource/*` label. +2. Move on to [prioritizing the issue](#4-prioritization-of-issues). + +### Documentation issue? + +First, evaluate if the documentation makes sense to be included in the Grafana project: + +- Is this something we want/can maintain as a project? +- Is this referring to usage of some specific integration/tool and in that case is that a popular use case in combination with Grafana? +- If unsure, kindly and politely add a comment explaining that we would need [upvotes](https://help.github.com/en/articles/about-conversations-on-github#reacting-to-ideas-in-comments) to identify that lots of other users want/need this. + +Second, label the issue `type/docs` and at least one `area/*` or `datasource/*` label. + +**Minor typo/error/lack of information:** + +There's a minor typo/error/lack of information that adds a lot of confusion for users and given the amount of work is a big win to make sure fixing it: +1. Either update the documentation yourself and open a pull request. +2. Either delegate the work to someone else by assigning that person to the issue and add the issue to next major/minor milestone. + +**Major error/lack of information:** + +1. Label the issue with `help wanted` and `beginner friendly`, if applicable, to signal that we find this important to fix and we would appreciate any help we can get from the community. +2. Move on to [prioritizing the issue](#4-prioritization-of-issues). + +### Accessibility issues + +1. Label the issue `type/accessibility` and at least one `area/*` or `datasource/*` label. + +### Support requests + +1. Kindly and politely direct the issue author to the [community site](https://community.grafana.com/) and explain that GitHub is mainly used for tracking bugs and feature requests. If possible, it's usually a good idea to add some pointers to the issue author's question. +2. Close the issue and label it with `type/question`. + +## 4. Prioritization of issues + +In general bugs and enhancement issues should be labeled with a priority. + +This is the most difficult thing with triaging issues since it requires a lot of knowledge, context and experience before being able to think of and start feel comfortable adding a certain priority label. + +The key here is asking for help and discuss issues to understand how more experienced project members think and reason. By doing that you learn more and eventually be more and more comfortable with prioritizing issues. + +In case there is an uncertainty around the prioritization of an issue, please ask the maintainers for help. + +| Label | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `priority/critical` | Highest priority. Must be actively worked on as someone's top priority right now. | +| `priority/support-subscription` | This is important for one or several customers having a paid Grafana support subscription. | +| `priority/important-soon` | Must be staffed and worked on either currently, or very soon, ideally in time for the next release. | +| `priority/important-longterm` | Important over the long term, but may not be staffed and/or may need multiple releases to complete. | +| `priority/nice-to-have` | It's a good idea, but not scheduled for any release. | +| `priority/awaiting-more-evidence` | Lowest priority. Possibly useful, but not yet enough interest in it. | +| `priority/unscheduled` | Something to look into before and to be discussed during the planning of the next (upcoming) major/minor stable release. | + +**Critical bugs** + +1. If a bug has been categorized and any of the following criteria apply, the bug should be labeled as critical and must be actively worked on as someone's top priority right now. + + - Results in any data loss + - Critical security or performance issues + - Problem that makes a feature unusable + - Multiple users experience a severe problem affecting their business, users etc. + +2. Label the issue `priority/critical`. +3. If applicable, label the issue `priority/support-subscription`. +4. Add the issue to the next upcoming patch release milestone. Create a new milestone if there are none. +5. Escalate the problem to the maintainers. +6. Assign or ask a maintainer for help assigning someone to make this issue their top priority right now. + +**Important short-term** + +1. Label the issue `priority/important-soon`. +2. If applicable, label the issue `priority/support-subscription`. +3. Add the issue to the next upcoming patch or major/minor stable release milestone. Ask maintainers for help if unsure if it's a patch or not. Create a new milestone if there are none. +4. Make sure to add the issue to a suitable backlog of a GitHub project and prioritize it or assign someone to work on it now or very soon. +5. Consider requesting [help from the community](#5-requesting-help-from-the-community), even though it may be problematic given a short amount of time until it should be released. + +**Important long-term** + +1. Label the issue `priority/important-longterm`. +2. Consider requesting [help from the community](#5-requesting-help-from-the-community). + +**Nice to have** + +1. Label the issue `priority/nice-to-have`. +2. Consider requesting [help from the community](#5-requesting-help-from-the-community). + +**Not critical, but unsure?** + +1. Label the issue `priority/unscheduled`. +2. Consider requesting [help from the community](#5-requesting-help-from-the-community). + +## 5. Requesting help from the community + +Depending on the issue and/or priority, it's always a good idea to consider signalling to the community that help from community is appreciated and needed in case an issue is not prioritized to be worked on by maintainers. Use your best judgement. In general, requesting help from the community means that a contribution has a good chance of getting accepted and merged. + +In many cases the issue author or community as a whole is more suitable to contribute changes since they're experts in their domain. It's also quite common that someone has tried to get something to work using the documentation without success and made an effort to get it to work and/or reached out to the [community site](https://community.grafana.com/) to get the missing information. Particularly in these areas it's more likely that there exist experts in their own domain and it is usually a good idea to request help from contributors: + +- Database setups +- Authentication like OAuth providers and LDAP setups +- Platform specific things +- Reverse proxy setups +- Alert notifiers + +1. Kindly and politely add a comment to signal to users subscribed to updates of the issue. + - Explain that the issue would be nice to get resolved, but it isn't prioritized to work on by maintainers for an unforeseen future. + - If possible or applicable, try to help contributors getting starting by adding pointers and references to what code/files need to be changed and/or ideas of a good way to solve/implement the issue. +2. Label the issue with `help wanted`. +3. If applicable, label the issue with `beginner friendly` to denote that the issue is suitable for a beginner to work on. +4. If possible, try to estimate the amount of work by adding `effort/small`, `effort/medium` or `effort/large`. + +## Investigation of issues + +When an issue has all basic information provided, but the triage responsible haven't been able to reproduce the reported problem at a first glance, the issue is labeled [Needs investigation](https://github.com/grafana/grafana/labels/needs%20investigation). Depending on the perceived severity and/or number of [upvotes](https://help.github.com/en/articles/about-conversations-on-github#reacting-to-ideas-in-comments), the investigation will either be delegated to another maintainer for further investigation or put on hold until someone else (maintainer or contributor) picks it up and eventually starts investigating it. + +Investigating issues can be a very time consuming task, especially for the maintainers, given the huge number of combinations of plugins, data sources, platforms, databases, browsers, tools, hardware, integrations, versions and cloud services, etc that are being used with Grafana. There is a certain number of combinations that are more common than others, and these are in general easier for maintainers to investigate. + +For some other combinations it may not be possible at all for a maintainer to setup a proper test environment to investigate the issue. In these cases we really appreciate any help we can get from the community. Otherwise the issue is highly likely to be closed. + +Even if you don't have the time or knowledge to investigate an issue we highly recommend that you [upvote](https://help.github.com/en/articles/about-conversations-on-github#reacting-to-ideas-in-comments) the issue if you happen to have the same problem. If you have further details that may help investigating the issue please provide as much information as possible. + +## Automation + +We have some automation that triggers on comments or labels being added to issues. Many of these automated behaviors are defined in [commands.json](https://github.com/grafana/grafana/blob/main/.github/commands.json). Or in other [GitHub Actions](https://github.com/grafana/grafana/tree/main/.github/workflows) + +* Add /duplicate `#` to have Grafana label & close issue with an appropriate message. +* Add `bot/question` and the bot will close it with an appropriate message. + +[Read more on bot actions](https://github.com/grafana/grafana/blob/main/.github/bot.md) + +## External PRs + +Part of issue triage should also be triaging of external PRs. Main goal should be to make sure PRs from external contributors have an owner/reviewer and are not forgotten. + +1. Check new external PRs which do not have a reviewer. +1. Check if there is a link to an existing issue. +1. If not and you know which issue it is solving, add the link yourself, otherwise ask the author to link the issue or create one. +1. Assign a reviewer based on who was handling the linked issue or what code or feature does the PR touches (look at who was the last to make changes there if all else fails). + +## Appendix + +### Setting up Gmail filters + +If you're using Gmail it's highly recommended that you setup filters to automatically remove email from the inbox and label them accordingly to make it easy for you to understand when you need to act upon a notification or process all incoming issues that haven't been triaged. + +This may be setup by personal preference, but here's a working configuration for reference. +1. Follow instructions in [gist](https://gist.github.com/marefr/9167c2e31466f6316c1cba118874e74f) +2. In Gmail, go to Settings -> Filters and Blocked Addresses +3. Import filters -> select xml file -> Open file +4. Review filters +5. Optional, Check Apply new filters to existing email +6. Create filters + +This will give you a structure of labels in the sidebar similar to the following: +``` + - Inbox + ... + - GitHub (mine) + - activity + - assigned + - mentions + - GitHub (other) + - Grafana +``` + +- All notifications you’ll need to read/take action on show up as unread in GitHub (mine) and its sub-labels. +- All other notifications you don’t need to take action on show up as unread in GitHub (other) and its sub-labels + - This is convenient for issue triage and to follow the activity in the Grafana project. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 0000000..a6fae5f --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,23 @@ +# Licensing + +License names used in this document are as per [SPDX License List](https://spdx.org/licenses/). + +The default license for this project is [AGPL-3.0-only](LICENSE). + +## Apache-2.0 + +The following folders and their subfolders are licensed under Apache-2.0: + +``` +packages/grafana-data/ +packages/grafana-e2e/ +packages/grafana-e2e-selectors/ +packages/grafana-runtime/ +packages/grafana-toolkit/ +packages/grafana-ui/ +packages/jaeger-ui-components/ +plugins-bundled/internal/input-datasource/ +packaging/ +grafana-mixin/ +cue/ +``` diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..beb4c27 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,8 @@ +@torkelo is the main/default maintainer, some parts of the codebase have other maintainers: + +- Backend: + - @bergquist +- Plugins: + - @ryantxu +- UX/UI: + - @davkal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c229609 --- /dev/null +++ b/Makefile @@ -0,0 +1,144 @@ +## This is a self-documented Makefile. For usage information, run `make help`: +## +## For more information, refer to https://suva.sh/posts/well-documented-makefiles/ + +-include local/Makefile + +.PHONY: all deps-go deps-js deps build-go build-server build-cli build-js build build-docker-dev build-docker-full lint-go golangci-lint test-go test-js test run run-frontend clean devenv devenv-down protobuf help + +GO = GO111MODULE=on go +GO_FILES ?= ./pkg/... +SH_FILES ?= $(shell find ./scripts -name *.sh) + +all: deps build + +##@ Dependencies + +deps-go: ## Install backend dependencies. + $(GO) run build.go setup + +deps-js: node_modules ## Install frontend dependencies. + +deps: deps-js ## Install all dependencies. + +node_modules: package.json yarn.lock ## Install node modules. + @echo "install frontend dependencies" + yarn install --pure-lockfile --no-progress + +##@ Building + +build-go: ## Build all Go binaries. + @echo "build go files" + $(GO) run build.go build + +build-server: ## Build Grafana server. + @echo "build server" + $(GO) run build.go build-server + +build-cli: ## Build Grafana CLI application. + @echo "build in CI environment" + $(GO) run build.go build-cli + +build-js: ## Build frontend assets. + @echo "build frontend" + yarn run build + yarn run plugins:build-bundled + +build: build-go build-js ## Build backend and frontend. + +scripts/go/bin/bra: scripts/go/go.mod + @cd scripts/go; \ + $(GO) build -o ./bin/bra github.com/unknwon/bra + +run: scripts/go/bin/bra ## Build and run web server on filesystem changes. + @GO111MODULE=on scripts/go/bin/bra run + +run-frontend: deps-js ## Fetch js dependencies and watch frontend for rebuild + yarn start + +##@ Testing + +test-go: ## Run tests for backend. + @echo "test backend" + $(GO) test -v ./pkg/... + +test-js: ## Run tests for frontend. + @echo "test frontend" + yarn test + +test: test-go test-js ## Run all tests. + +##@ Linting +scripts/go/bin/golangci-lint: scripts/go/go.mod + @cd scripts/go; \ + $(GO) build -o ./bin/golangci-lint github.com/golangci/golangci-lint/cmd/golangci-lint + +golangci-lint: scripts/go/bin/golangci-lint + @echo "lint via golangci-lint" + @scripts/go/bin/golangci-lint run \ + --config ./scripts/go/configs/.golangci.toml \ + $(GO_FILES) + +lint-go: golangci-lint # Run all code checks for backend. + +# with disabled SC1071 we are ignored some TCL,Expect `/usr/bin/env expect` scripts +shellcheck: $(SH_FILES) ## Run checks for shell scripts. + @docker run --rm -v "$$PWD:/mnt" koalaman/shellcheck:stable \ + $(SH_FILES) -e SC1071 -e SC2162 + +##@ Docker + +build-docker-dev: ## Build Docker image for development (fast). + @echo "build development container" + @echo "\033[92mInfo:\033[0m the frontend code is expected to be built already." + $(GO) run build.go -goos linux -pkg-arch amd64 ${OPT} build pkg-archive latest + cp dist/grafana-latest.linux-x64.tar.gz packaging/docker + cd packaging/docker && docker build --tag grafana/grafana:dev . + +build-docker-full: ## Build Docker image for development. + @echo "build docker container" + docker build --tag grafana/grafana:dev . + +##@ Services + +# create docker-compose file with provided sources and start them +# example: make devenv sources=postgres,openldap +ifeq ($(sources),) +devenv: + @printf 'You have to define sources for this command \nexample: make devenv sources=postgres,openldap\n' +else +devenv: devenv-down ## Start optional services, e.g. postgres, prometheus, and elasticsearch. + $(eval targets := $(shell echo '$(sources)' | tr "," " ")) + + @cd devenv; \ + ./create_docker_compose.sh $(targets) || \ + (rm -rf {docker-compose.yaml,conf.tmp,.env}; exit 1) + + @cd devenv; \ + docker-compose up -d --build +endif + +devenv-down: ## Stop optional services. + @cd devenv; \ + test -f docker-compose.yaml && \ + docker-compose down || exit 0; + +##@ Helpers + +# We separate the protobuf generation because most development tasks on +# Grafana do not involve changing protobuf files and protoc is not a +# go-gettable dependency and so getting it installed can be inconvenient. +# +# If you are working on changes to protobuf interfaces you may either use +# this target or run the individual scripts below directly. +protobuf: ## Compile protobuf definitions + bash scripts/protobuf-check.sh + bash pkg/plugins/backendplugin/pluginextensionv2/generate.sh + +clean: ## Clean up intermediate build artifacts. + @echo "cleaning" + rm -rf node_modules + rm -rf public/build + +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..40cf840 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,6 @@ + +Copyright 2014-2021 Grafana Labs + +This software is based on Kibana: +Copyright 2012-2013 Elasticsearch BV + diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md new file mode 100644 index 0000000..27ddacf --- /dev/null +++ b/PLUGIN_DEV.md @@ -0,0 +1,33 @@ +# Plugin development + +This document is not meant as a complete guide for developing plugins but more as a changelog for changes in +Grafana that can impact plugin development. Whenever you as a plugin author encounter an issue with your plugin after +upgrading Grafana please check here before creating an issue. + +## Plugin development resources + +- [Grafana plugin developer guide](http://docs.grafana.org/plugins/developing/development/) +- [Webpack Grafana plugin template project](https://github.com/CorpGlory/grafana-plugin-template-webpack) +- [Simple JSON datasource plugin](https://github.com/grafana/simple-json-datasource) + +## Changes in Grafana v4.6 + +This version of Grafana has big changes that will impact a limited set of plugins. We moved from systemjs to webpack +for built-in plugins and everything internal. External plugins still use systemjs but now with a limited +set of Grafana components they can import. Plugins can depend on libs like lodash & moment and internal components +like before using the same import paths. However since everything in Grafana is no longer accessible, a few plugins could encounter issues when importing a Grafana dependency. + +[List of exposed components plugins can import/require](https://github.com/grafana/grafana/blob/main/public/app/features/plugins/plugin_loader.ts#L48) + +If you think we missed exposing a crucial lib or Grafana component let us know by opening an issue. + +### Deprecated components + +The angular directive `` is now deprecated (will still work for a version more) but we recommend plugin authors +upgrade to new `` + +## Changes in Grafana v6.0 + +### DashboardSrv.ts + +If you utilize [DashboardSrv](https://github.com/grafana/grafana/commit/8574dca081002f36e482b572517d8f05fd44453f#diff-1ab99561f9f6a10e1fafcddc39bc1d65) in your plugin code, `dash` was renamed to `dashboard`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5ac19a --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +![Grafana](docs/logo-horizontal.png) + +The open-source platform for monitoring and observability. + +[![License](https://img.shields.io/github/license/grafana/grafana)](LICENSE) +[![Drone](https://drone.grafana.net/api/badges/grafana/grafana/status.svg)](https://drone.grafana.net/grafana/grafana) +[![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana) + +Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data driven culture: + +- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins offer many different ways to visualize metrics and logs. +- **Dynamic Dashboards:** Create dynamic & reusable dashboards with template variables that appear as dropdowns at the top of the dashboard. +- **Explore Metrics:** Explore your data through ad-hoc queries and dynamic drilldown. Split view and compare different time ranges, queries and data sources side by side. +- **Explore Logs:** Experience the magic of switching from metrics to logs with preserved label filters. Quickly search through all your logs or streaming them live. +- **Alerting:** Visually define alert rules for your most important metrics. Grafana will continuously evaluate and send notifications to systems like Slack, PagerDuty, VictorOps, OpsGenie. +- **Mixed Data Sources:** Mix different data sources in the same graph! You can specify a data source on a per-query basis. This works for even custom datasources. + +## Get started + +- [Get Grafana](https://grafana.com/get) +- [Installation guides](http://docs.grafana.org/installation/) + +Unsure if Grafana is for you? Watch Grafana in action on [play.grafana.org](https://play.grafana.org/)! + +## Documentation + +The Grafana documentation is available at [grafana.com/docs](https://grafana.com/docs/). + +## Contributing + +If you're interested in contributing to the Grafana project: + +- Start by reading the [Contributing guide](/CONTRIBUTING.md). +- Learn how to set up your local environment, in our [Developer guide](/contribute/developer-guide.md). +- Explore our [beginner-friendly issues](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22). +- Look through our [style guide and Storybook](https://developers.grafana.com/ui/latest/index.html). + +## Get involved + +- Follow [@grafana on Twitter](https://twitter.com/grafana/). +- Read and subscribe to the [Grafana blog](https://grafana.com/blog/). +- If you have a specific question, check out our [discussion forums](https://community.grafana.com/). +- For general discussions, join us on the [official Slack](http://slack.raintank.io/) team. + +## License + +Grafana is distributed under [AGPL-3.0-only](LICENSE). For Apache-2.0 exceptions, see [LICENSING.md](LICENSING.md). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d0e69b0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,5 @@ +# Roadmap + +The roadmap is a tentative plan for the core development team. Things change constantly as pull requests come in and priorities change, but it will give you an idea of our current vision and plan. + +To view the Roadmap, go to the Issues tab on GitHub. There you will find three roadmap issues pinned at the top. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..355a588 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Reporting security issues + +If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. + +Please encrypt your message to us; please use our PGP key. The key fingerprint is: + +F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA + +The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA&fingerprint=on&op=index). + +Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. + +**Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. + +## Security announcements + +We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/security-announcements), +where we will post a summary, remediation, and mitigation details for any patch containing security fixes. + +You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/security-announcements.rss). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..c9c94d9 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,10 @@ +# Get Grafana help +------------------ +First, check the official [Grafana documentation](https://grafana.com/docs/). + +If you require further help or support then ask a question in the [Grafana community site](https://community.grafana.com/) or [Grafana Slack](http://slack.raintank.io/). You can also search the community site for previously answered questions, in case someone already had your problem and got help. + + **Please note:** +- The Grafana project uses GitHub mainly for tracking bugs and feature requests. +- Do not open an issue just to ask a question. The issue will be closed immediately. +- Only submit issues for bug reports, feature requests, or enhancements. diff --git a/UPGRADING_DEPENDENCIES.md b/UPGRADING_DEPENDENCIES.md new file mode 100644 index 0000000..3f3c87d --- /dev/null +++ b/UPGRADING_DEPENDENCIES.md @@ -0,0 +1,108 @@ +# 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 + +The Grafana project uses [Go modules](https://golang.org/cmd/go/#hdr-Modules__module_versions__and_more) to manage dependencies on external packages. This requires a working Go environment with version 1.11 or greater installed. + +> **Note:** Since most developers of Grafana still use the `GOPATH` we need to specify `GO111MODULE=on` to make `go mod` and `got get` work as intended. If you have setup Grafana outside of the `GOPATH` on your machine you can skip `GO111MODULE=on` when running the commands below. + +To add or update a new dependency, use the `go get` command: + +```bash +# The GO111MODULE variable can be omitted when the code isn't located in GOPATH. +# Pick the latest tagged release. +GO111MODULE=on go get example.com/some/module/pkg + +# Pick a specific version. +GO111MODULE=on go get example.com/some/module/pkg@vX.Y.Z +``` + +Tidy up the `go.mod` and `go.sum` files: + +```bash +# The GO111MODULE variable can be omitted when the code isn't located in GOPATH. +GO111MODULE=on go mod tidy +``` + +You have to commit the changes to `go.mod` and `go.sum` before submitting the pull request. + +## 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 necessary dependencies. + +Link: [grafana/build-container](https://github.com/grafana/grafana/tree/main/scripts/build/ci-build) + +#### Dependencies + +- fpm +- nodejs +- golang +- crosscompiling (several compilers) + +### Appveyor + +Main 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. diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 0000000..d369eb6 --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,77 @@ +# Grafana workflow + +This document is based on [GOVERNANCE.md](GOVERNANCE.md). We assume good faith and intend to keep all processes as lightweight as possible but as specific as required. In case of disagreements about anything in this document, GOVERNANCE.md applies. + +The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in [RFC2119](http://tools.ietf.org/html/rfc2119). + +Git and [GitHub terminology](https://help.github.com/en/github/getting-started-with-github/github-glossary) are used throughout this document. + +Team members and their access to repositories is maintained through [GitHub teams](https://help.github.com/en/github/setting-up-and-managing-organizations-and-teams/about-teams). Team maintainers add and remove team members as outlined in GOVERNANCE.md. + +# Code changes + +## Proposing changes + +Examples of proposed changes are overarching architecture, component design, and specific code or graphical elements. Proposed changes SHOULD cover the big picture and intention, but individual parts SHOULD be split into the smallest possible changes. Changes SHOULD be based on and target the main branch. Depending on size of the proposed change, each change SHOULD be discussed, in increasing order of change size and complexity: +- Directly in a RR (Pull Request) - this MAY be done, but SHOULD not be the common case. +- Issue +- Developer mailing list +- Design document, shared via Google Docs, accessible to at least all team members. + +Significant changes MUST be discussed and agreed upon with the relevant subsystem maintainers. + +## Merging PRs (Pull Requests) + +Depending on the size and complexity of a PR, different requirements MUST be applied. Any team member contributing substantially to a PR MUST NOT count against review requirements. +Commits MUST be merged into main using PRs. They MUST NOT be merged into main directly. +- Every merge MUST be approved by at least one team member. +- Non-trivial changes MUST be approved by at least + - two team members, or + - one subsystem maintainer. +- Significant changes MUST be approved by at least + - two team members, AND + - the relevant subsystem maintainer. + +PRs MUST be [reviewed](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/reviewing-changes-in-pull-requests) and [approved](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/approving-a-pull-request-with-required-reviews) via GitHub’s review system. +- Reviewers MAY write comments if approving +- Reviewers MUST write comments if rejecting a PR or if requesting changes. + +Once a PR is approved as per above, any team member MAY merge the PR. + +## Backporting a PR + +PRs intended for inclusion in the next PATCH release they must be backported to the release branch. The bot can do this automatically. [Read more on backport PRs](https://github.com/grafana/grafana/blob/main/.github/bot.md). Both the source PR and the backport PR should be assigned to the patch release milestone, unless you are backporting to many releases then it can differ. + +Backport PRs are also needed during the beta period to get fixes into the stable release. + +# Release workflow + +## Branch structure + +Grafana uses trunk-based development. + +In particular, we found that the following principles match how we work: +- Main and release branches MUST always build without failure. +- Branches SHOULD be merged often. Larger changes SHOULD be activated with feature flags until they are ready. Long-lived development branches SHOULD be avoided. +- Changes MAY be enabled by default once they are in a complete state +- Changes which span multiple PRs MUST be described in an overarching issue or Google Doc. + +## Releases + +Releases MUST follow [Semantic Versioning](https://semver.org/) in naming and SHOULD follow Semantic Versioning as closely as reasonably possible for non-library software. + +Release branches MUST be split from the following branches. +- MAJOR release branches MUST be based on main. +- MINOR release branches MUST be based on main. +- PATCH release branches MUST be split from the relevant MINOR release branch’s most current PATCH + +Security releases follow the same process but MUST be prepared in secret. Security releases MUST NOT include changes which are not related to the security fix. Normal release processes MUST accommodate the security release process. SECURITY.md MUST be followed. + +Releases follow the following cadence +- MAJOR: Yearly +- MINOR: Every 4-6 weeks +- PATCH: As needed + +Releases SHOULD NOT be delayed by pending changes. + +Releases MUST be coordinated with the relevant subsystem maintainers. diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 0000000..b73e800 --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/dist/index.d.ts", + "bundledPackages": [], + "compiler": {}, + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": true, + "apiJsonFilePath": "/../../reports/docs/.api.json" + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": {}, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + }, + "ae-internal-missing-underscore": { + "logLevel": "none", + "addToApiReportFile": false + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/build.go b/build.go new file mode 100644 index 0000000..9cb911a --- /dev/null +++ b/build.go @@ -0,0 +1,482 @@ +// +build ignore + +package main + +import ( + "bytes" + "crypto/md5" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "go/build" + "io" + "io/ioutil" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + windows = "windows" + linux = "linux" +) + +var ( + //versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`) + goarch string + goos string + gocc string + cgo bool + libc string + pkgArch string + version string = "v1" + buildTags []string + // deb & rpm does not support semver so have to handle their version a little differently + linuxPackageVersion string = "v1" + linuxPackageIteration string = "" + race bool + workingDir string + includeBuildId bool = true + buildId string = "0" + serverBinary string = "grafana-server" + cliBinary string = "grafana-cli" + binaries []string = []string{serverBinary, cliBinary} + isDev bool = false + enterprise bool = false + skipRpmGen bool = false + skipDebGen bool = false + printGenVersion bool = false +) + +func main() { + log.SetOutput(os.Stdout) + log.SetFlags(0) + + var buildIdRaw string + var buildTagsRaw string + + flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") + flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") + flag.StringVar(&gocc, "cc", "", "CC") + flag.StringVar(&libc, "libc", "", "LIBC") + flag.StringVar(&buildTagsRaw, "build-tags", "", "Sets custom build tags") + flag.BoolVar(&cgo, "cgo-enabled", cgo, "Enable cgo") + flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") + flag.BoolVar(&race, "race", race, "Use race detector") + flag.BoolVar(&includeBuildId, "includeBuildId", includeBuildId, "IncludeBuildId in package name") + flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana") + flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system") + flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps") + flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)") + flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)") + flag.BoolVar(&printGenVersion, "gen-version", printGenVersion, "generate Grafana version and output (default: false)") + flag.Parse() + + buildId = shortenBuildId(buildIdRaw) + + readVersionFromPackageJson() + + if pkgArch == "" { + pkgArch = goarch + } + + if printGenVersion { + printGeneratedVersion() + return + } + + if len(buildTagsRaw) > 0 { + buildTags = strings.Split(buildTagsRaw, ",") + } + + log.Printf("Version: %s, Linux Version: %s, Package Iteration: %s\n", version, linuxPackageVersion, linuxPackageIteration) + + if flag.NArg() == 0 { + log.Println("Usage: go run build.go build") + return + } + + workingDir, _ = os.Getwd() + + for _, cmd := range flag.Args() { + switch cmd { + case "setup": + setup() + + case "build-srv", "build-server": + clean() + doBuild("grafana-server", "./pkg/cmd/grafana-server", buildTags) + + case "build-cli": + clean() + doBuild("grafana-cli", "./pkg/cmd/grafana-cli", buildTags) + + case "build": + //clean() + for _, binary := range binaries { + doBuild(binary, "./pkg/cmd/"+binary, buildTags) + } + + case "build-frontend": + yarn("build") + + case "sha-dist": + shaFilesInDist() + + case "latest": + makeLatestDistCopies() + + case "clean": + clean() + + default: + log.Fatalf("Unknown command %q", cmd) + } + } +} + +func makeLatestDistCopies() { + files, err := ioutil.ReadDir("dist") + if err != nil { + log.Fatalf("failed to create latest copies. Cannot read from /dist") + } + + latestMapping := map[string]string{ + "_amd64.deb": "dist/grafana_latest_amd64.deb", + ".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm", + ".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz", + ".linux-amd64-musl.tar.gz": "dist/grafana-latest.linux-x64-musl.tar.gz", + ".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz", + ".linux-armv7-musl.tar.gz": "dist/grafana-latest.linux-armv7-musl.tar.gz", + ".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz", + ".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz", + ".linux-arm64-musl.tar.gz": "dist/grafana-latest.linux-arm64-musl.tar.gz", + } + + for _, file := range files { + for extension, fullName := range latestMapping { + if strings.HasSuffix(file.Name(), extension) { + runError("cp", path.Join("dist", file.Name()), fullName) + } + } + } +} + +func readVersionFromPackageJson() { + reader, err := os.Open("package.json") + if err != nil { + log.Fatal("Failed to open package.json") + return + } + defer reader.Close() + + jsonObj := map[string]interface{}{} + jsonParser := json.NewDecoder(reader) + + if err := jsonParser.Decode(&jsonObj); err != nil { + log.Fatal("Failed to decode package.json") + } + + version = jsonObj["version"].(string) + linuxPackageVersion = version + linuxPackageIteration = "" + + // handle pre version stuff (deb / rpm does not support semver) + parts := strings.Split(version, "-") + + if len(parts) > 1 { + linuxPackageVersion = parts[0] + linuxPackageIteration = parts[1] + } + + // add timestamp to iteration + if includeBuildId { + if buildId != "0" { + linuxPackageIteration = fmt.Sprintf("%s%s", buildId, linuxPackageIteration) + } else { + linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) + } + } +} + +func yarn(params ...string) { + runPrint(`yarn run`, params...) +} + +func genPackageVersion() string { + if includeBuildId { + return fmt.Sprintf("%v-%v", linuxPackageVersion, linuxPackageIteration) + } else { + return version + } +} + +func setup() { + args := []string{"install", "-v"} + if goos == windows { + args = append(args, "-buildmode=exe") + } + args = append(args, "./pkg/cmd/grafana-server") + runPrint("go", args...) +} + +func printGeneratedVersion() { + fmt.Print(genPackageVersion()) +} + +func test(pkg string) { + setBuildEnv() + args := []string{"test", "-short", "-timeout", "60s"} + if goos == windows { + args = append(args, "-buildmode=exe") + } + args = append(args, pkg) + runPrint("go", args...) +} + +func doBuild(binaryName, pkg string, tags []string) { + libcPart := "" + if libc != "" { + libcPart = fmt.Sprintf("-%s", libc) + } + binary := fmt.Sprintf("./bin/%s-%s%s/%s", goos, goarch, libcPart, binaryName) + if isDev { + //don't include os/arch/libc in output path in dev environment + binary = fmt.Sprintf("./bin/%s", binaryName) + } + + if goos == windows { + binary += ".exe" + } + + if !isDev { + rmr(binary, binary+".md5") + } + args := []string{"build", "-ldflags", ldflags()} + if goos == windows { + // Work around a linking error on Windows: "export ordinal too large" + args = append(args, "-buildmode=exe") + } + if len(tags) > 0 { + args = append(args, "-tags", strings.Join(tags, ",")) + } + if race { + args = append(args, "-race") + } + + args = append(args, "-o", binary) + args = append(args, pkg) + + if !isDev { + setBuildEnv() + runPrint("go", "version") + libcPart := "" + if libc != "" { + libcPart = fmt.Sprintf("/%s", libc) + } + fmt.Printf("Targeting %s/%s%s\n", goos, goarch, libcPart) + } + + runPrint("go", args...) + + if !isDev { + // Create an md5 checksum of the binary, to be included in the archive for + // automatic upgrades. + err := md5File(binary) + if err != nil { + log.Fatal(err) + } + } +} + +func ldflags() string { + var b bytes.Buffer + b.WriteString("-w") + 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())) + if v := os.Getenv("LDFLAGS"); v != "" { + b.WriteString(fmt.Sprintf(" -extldflags \"%s\"", v)) + } + return b.String() +} + +func rmr(paths ...string) { + for _, path := range paths { + log.Println("rm -r", path) + os.RemoveAll(path) + } +} + +func clean() { + if isDev { + return + } + + rmr("dist") + rmr("tmp") + rmr(filepath.Join(build.Default.GOPATH, fmt.Sprintf("pkg/%s_%s/github.com/grafana", goos, goarch))) +} + +func setBuildEnv() { + os.Setenv("GOOS", goos) + if goos == windows { + // require windows >=7 + os.Setenv("CGO_CFLAGS", "-D_WIN32_WINNT=0x0601") + } + if goarch != "amd64" || goos != linux { + // needed for all other archs + cgo = true + } + if strings.HasPrefix(goarch, "armv") { + os.Setenv("GOARCH", "arm") + os.Setenv("GOARM", goarch[4:]) + } else { + os.Setenv("GOARCH", goarch) + } + if goarch == "386" { + os.Setenv("GO386", "387") + } + if cgo { + os.Setenv("CGO_ENABLED", "1") + } + if gocc != "" { + os.Setenv("CC", gocc) + } +} + +func getGitBranch() string { + v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "main" + } + return string(v) +} + +func getGitSha() string { + v, err := runError("git", "rev-parse", "--short", "HEAD") + if err != nil { + return "unknown-dev" + } + return string(v) +} + +func buildStamp() int64 { + // use SOURCE_DATE_EPOCH if set. + if s, _ := strconv.ParseInt(os.Getenv("SOURCE_DATE_EPOCH"), 10, 64); s > 0 { + return s + } + + bs, err := runError("git", "show", "-s", "--format=%ct") + if err != nil { + return time.Now().Unix() + } + s, _ := strconv.ParseInt(string(bs), 10, 64) + return s +} + +func runError(cmd string, args ...string) ([]byte, error) { + ecmd := exec.Command(cmd, args...) + bs, err := ecmd.CombinedOutput() + if err != nil { + return nil, err + } + + return bytes.TrimSpace(bs), nil +} + +func runPrint(cmd string, args ...string) { + log.Println(cmd, strings.Join(args, " ")) + ecmd := exec.Command(cmd, args...) + ecmd.Env = append(os.Environ(), "GO111MODULE=on") + ecmd.Stdout = os.Stdout + ecmd.Stderr = os.Stderr + err := ecmd.Run() + if err != nil { + log.Fatal(err) + } +} + +func md5File(file string) error { + fd, err := os.Open(file) + if err != nil { + return err + } + defer fd.Close() + + h := md5.New() + _, err = io.Copy(h, fd) + if err != nil { + return err + } + + out, err := os.Create(file + ".md5") + if err != nil { + return err + } + + _, err = fmt.Fprintf(out, "%x\n", h.Sum(nil)) + if err != nil { + return err + } + + return out.Close() +} + +func shaFilesInDist() { + filepath.Walk("./dist", func(path string, f os.FileInfo, err error) error { + if path == "./dist" { + return nil + } + + if !strings.Contains(path, ".sha256") { + err := shaFile(path) + if err != nil { + log.Printf("Failed to create sha file. error: %v\n", err) + } + } + return nil + }) +} + +func shaFile(file string) error { + fd, err := os.Open(file) + if err != nil { + return err + } + defer fd.Close() + + h := sha256.New() + _, err = io.Copy(h, fd) + if err != nil { + return err + } + + out, err := os.Create(file + ".sha256") + if err != nil { + return err + } + + _, err = fmt.Fprintf(out, "%x\n", h.Sum(nil)) + if err != nil { + return err + } + + return out.Close() +} + +func shortenBuildId(buildId string) string { + buildId = strings.Replace(buildId, "-", "", -1) + if len(buildId) < 9 { + return buildId + } + return buildId[0:8] +} diff --git a/conf/defaults.ini b/conf/defaults.ini new file mode 100644 index 0000000..009a70e --- /dev/null +++ b/conf/defaults.ini @@ -0,0 +1,973 @@ +##################### Grafana Configuration Defaults ##################### +# +# Do not modify this file in grafana installs +# + +# possible values : production, development +app_mode = production + +# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty +instance_name = ${HOSTNAME} + +#################################### Paths ############################### +[paths] +# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) +data = data + +# Temporary files in `data` directory older than given duration will be removed +temp_data_lifetime = 24h + +# Directory where grafana can store logs +logs = data/log + +# Directory where grafana will automatically scan and look for plugins +plugins = data/plugins + +# folder that contains provisioning config files that grafana will apply on startup and while running. +provisioning = conf/provisioning + +#################################### Server ############################## +[server] +# Protocol (http, https, h2, socket) +protocol = http + +# The ip address to bind to, empty will bind to all interfaces +http_addr = + +# The http port to use +http_port = 3000 + +# The public facing domain name used to access grafana from a browser +domain = localhost + +# Redirect to correct domain if host header does not match domain +# Prevents DNS rebinding attacks +enforce_domain = false + +# The full public facing url +root_url = %(protocol)s://%(domain)s:%(http_port)s/ + +# Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons. +serve_from_sub_path = false + +# Log web requests +router_logging = false + +# the path relative working path +static_root_path = public + +# enable gzip +enable_gzip = false + +# https certs & key file +cert_file = +cert_key = + +# Unix socket path +socket = /tmp/grafana.sock + +# CDN Url +cdn_url = + +# Sets the maximum time in minutes before timing out read of an incoming request and closing idle connections. +# `0` means there is no timeout for reading the request. +read_timeout = 0 + +#################################### Database ############################ +[database] +# You can configure the database connection by specifying type, host, name, user and password +# as separate properties or as on string using the url property. + +# Either "mysql", "postgres" or "sqlite3", it's your choice +type = sqlite3 +host = 127.0.0.1:3306 +name = grafana +user = root +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" +password = +# Use either URL or the previous fields to configure the database +# Example: mysql://user:secret@host:port/database +url = + +# Max idle conn setting default is 2 +max_idle_conn = 2 + +# Max conn setting default is 0 (mean not set) +max_open_conn = + +# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours) +conn_max_lifetime = 14400 + +# Set to true to log the sql calls and execution times. +log_queries = + +# For "postgres", use either "disable", "require" or "verify-full" +# For "mysql", use either "true", "false", or "skip-verify". +ssl_mode = disable + +# Database drivers may support different transaction isolation levels. +# Currently, only "mysql" driver supports isolation levels. +# If the value is empty - driver's default isolation level is applied. +# For "mysql" use "READ-UNCOMMITTED", "READ-COMMITTED", "REPEATABLE-READ" or "SERIALIZABLE". +isolation_level = + +ca_cert_path = +client_key_path = +client_cert_path = +server_cert_name = + +# For "sqlite3" only, path relative to data_path setting +path = grafana.db + +# For "sqlite3" only. cache mode setting used for connecting to the database +cache_mode = private + +#################################### Cache server ############################# +[remote_cache] +# Either "redis", "memcached" or "database" default is "database" +type = database + +# cache connectionstring options +# database: will use Grafana primary database. +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`. Only addr is required. ssl may be 'true', 'false', or 'insecure'. +# memcache: 127.0.0.1:11211 +connstr = + +#################################### Data proxy ########################### +[dataproxy] + +# This enables data proxy logging, default is false +logging = false + +# How long the data proxy waits before timing out, default is 30 seconds. +# This setting also applies to core backend HTTP data sources where query requests use an HTTP client with timeout set. +timeout = 30 + +# How many seconds the data proxy waits before sending a keepalive request. +keep_alive_seconds = 30 + +# How many seconds the data proxy waits for a successful TLS Handshake before timing out. +tls_handshake_timeout_seconds = 10 + +# How many seconds the data proxy will wait for a server's first response headers after +# fully writing the request headers if the request has an "Expect: 100-continue" +# header. A value of 0 will result in the body being sent immediately, without +# waiting for the server to approve. +expect_continue_timeout_seconds = 1 + +# The maximum number of idle connections that Grafana will keep alive. +max_idle_connections = 100 + +# How many seconds the data proxy keeps an idle connection open before timing out. +idle_conn_timeout_seconds = 90 + +# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request. +send_user_header = false + +#################################### Analytics ########################### +[analytics] +# Server reporting, sends usage counters to stats.grafana.org every 24 hours. +# No ip addresses are being tracked, only simple counters to track +# running instances, dashboard and error counts. It is very helpful to us. +# Change this option to false to disable reporting. +reporting_enabled = true + +# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs +reporting_distributor = grafana-labs + +# Set to false to disable all checks to https://grafana.com +# for new versions (grafana itself and plugins), check is used +# in some UI views to notify that grafana or plugin update exists +# This option does not cause any auto updates, nor send any information +# only a GET request to https://grafana.com to get latest versions +check_for_updates = true + +# Google Analytics universal tracking code, only enabled if you specify an id here +google_analytics_ua_id = + +# Google Tag Manager ID, only enabled if you specify an id here +google_tag_manager_id = + +#################################### Security ############################ +[security] +# disable creation of admin user on first start of grafana +disable_initial_admin_creation = false + +# default admin user, created on startup +admin_user = admin + +# default admin password, can be changed before first start of grafana, or in profile settings +admin_password = admin + +# used for signing +secret_key = SW2YcwTIb9zpOOhoPsMm + +# disable gravatar profile images +disable_gravatar = false + +# data source proxy whitelist (ip_or_domain:port separated by spaces) +data_source_proxy_whitelist = + +# disable protection against brute force login attempts +disable_brute_force_login_protection = false + +# set to true if you host Grafana behind HTTPS. default is false. +cookie_secure = false + +# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict", "none" and "disabled" +cookie_samesite = lax + +# set to true if you want to allow browsers to render Grafana in a , +``` + +The result is an interactive Grafana graph embedded in an iframe: + + diff --git a/docs/sources/troubleshooting/_index.md b/docs/sources/troubleshooting/_index.md new file mode 100644 index 0000000..c72de3f --- /dev/null +++ b/docs/sources/troubleshooting/_index.md @@ -0,0 +1,43 @@ ++++ +title = "Troubleshooting" +description = "Guide to troubleshooting Grafana problems" +keywords = ["grafana", "troubleshooting", "documentation", "guide"] +weight = 180 ++++ + +# Troubleshooting + +This page lists some tools and advice to help troubleshoot common Grafana issues. + +## Troubleshoot with logs + +If you encounter an error or problem, then you can check the Grafana server log. Usually located at `/var/log/grafana/grafana.log` on Unix systems or in `/data/log` on other platforms and manual installations. + +You can enable more logging by changing log level in the Grafana configuration file. + +For more information, refer to [Enable debug logging in Grafana CLI]({{< relref "../administration/cli.md#enable-debug-logging" >}}) and the [log section in Configuration]({{< relref "../administration/configuration.md#log" >}}). + +## Troubleshoot transformations + +Order of transformations matters. If the final data output from multiple transformations looks wrong, try changing the transformation order. Each transformation transforms data returned by the previous transformation, not the original raw data. + +For more information, refer to [Debug transformations]({{< relref "../panels/transformations/apply-transformations.md" >}}). + +## Text missing with server-side image rendering (RPM-based Linux) + +Server-side image (png) rendering is a feature that is optional but very useful when sharing visualizations, for example in alert notifications. + +If the image is missing text, then make sure you have font packages installed. + +```bash +sudo yum install fontconfig +sudo yum install freetype* +sudo yum install urw-fonts +``` + +## FAQs + +Check out the [FAQ section](https://community.grafana.com/c/howto/faq) on the Grafana Community page for answers to frequently +asked questions. + + diff --git a/docs/sources/troubleshooting/diagnostics.md b/docs/sources/troubleshooting/diagnostics.md new file mode 100644 index 0000000..01363f8 --- /dev/null +++ b/docs/sources/troubleshooting/diagnostics.md @@ -0,0 +1,55 @@ ++++ +title = "Enable diagnostics" +weight = 200 ++++ + +# Enable diagnostics + +You can set up the `grafana-server` process to enable certain diagnostics when it starts. This can be helpful +when investigating certain performance problems. It's *not* recommended to have these enabled by default. + +## Turn on profiling + +The `grafana-server` can be started with the arguments `-profile` to enable profiling and `-profile-port` to override +the default HTTP port (`6060`) where the `pprof` debugging endpoints are available, for example: + +```bash +./grafana-server -profile -profile-port=8080 +``` + +Note that `pprof` debugging endpoints are served on a different port than the Grafana HTTP server. + +You can configure or override profiling settings using environment variables: + +```bash +export GF_DIAGNOSTICS_PROFILING_ENABLED=true +export GF_DIAGNOSTICS_PROFILING_PORT=8080 +``` + +Refer to [Go command pprof](https://golang.org/cmd/pprof/) for more information about how to collect and analyze profiling data. + +## Use tracing + +The `grafana-server` can be started with the arguments `-tracing` to enable tracing and `-tracing-file` to override the default trace file (`trace.out`) where trace result is written to. For example: + +```bash +./grafana-server -tracing -tracing-file=/tmp/trace.out +``` + +You can configure or override profiling settings using environment variables: + +```bash +export GF_DIAGNOSTICS_TRACING_ENABLED=true +export GF_DIAGNOSTICS_TRACING_FILE=/tmp/trace.out +``` + +View the trace in a web browser (Go required to be installed): + +```bash +go tool trace +2019/11/24 22:20:42 Parsing trace... +2019/11/24 22:20:42 Splitting trace... +2019/11/24 22:20:42 Opening browser. Trace viewer is listening on http://127.0.0.1:39735 +``` + +See [Go command trace](https://golang.org/cmd/trace/) for more information about how to analyze trace files. \ No newline at end of file diff --git a/docs/sources/troubleshooting/troubleshoot-dashboards.md b/docs/sources/troubleshooting/troubleshoot-dashboards.md new file mode 100644 index 0000000..41c8f1d --- /dev/null +++ b/docs/sources/troubleshooting/troubleshoot-dashboards.md @@ -0,0 +1,42 @@ ++++ +title = "Troubleshoot dashboards" +description = "Guide to troubleshooting Grafana dashboards" +keywords = ["grafana", "troubleshooting", "documentation", "dashboards"] +weight = 100 ++++ + +# Troubleshoot dashboards + +This page provides information to solve common dashboard problems. + +## Dashboard is slow + +- Are you trying to render dozens (or hundreds or thousands) of time-series on a graph? This can cause the browser to lag and feel sluggish. Try using functions like `highestMax` (in Graphite) to reduce the returned series. +- Sometimes the series names can be very large. This causes larger response sizes. Try using `alias` to reduce the size of the returned series names. +- Are you querying many time-series or for a long range of time? Both of these can cause Grafana or your data source to pull in a lot of data, which may slow it down. +- It could be high load on your network infrastructure. If the slowness isn't consistent, this may be the problem. + +## Dashboard refresh rate issues + +By default, Grafana queries your data source every 30 seconds. Setting a low refresh rate on your dashboards puts unnecessary stress on the backend. In many cases, querying this frequently makes no sense, because the data isn't being sent to the system such that changes would be seen. + +We recommend the following: + +- Do not enable auto-refreshing on dashboards, panels, or variables unless you need it. Users can refresh their browser manually, or you can set the refresh rate for a time period that makes sense (every ten minutes, every hour, and so on). +- If it is required, then set the refresh rate to once a minute. Again, users can always refresh the dashboard manually. +- If your dashboard has a longer time period (such as a week), then you really don't need automated refreshing. + +### Handling or rendering null data is wrong/confusing/weird + +Some applications publish data intermittently; for example, they only post a metric when an event occurs. By +default, Grafana graphs connect lines between the data points. This can be very deceiving. + +In the picture below we have enabled: +- Points and 3-point radius to highlight where data points are actually present. +- **Null value** is set to **connected**. + +{{< docs-imagebox img="/img/docs/troubleshooting/grafana_null_connected.png" max-width="1200px" >}} + +In this graph, we set graph to show bars instead of lines and set the **Null value** to graph **null as zero**. There is a very big different in the visuals. + +{{< docs-imagebox img="/img/docs/troubleshooting/grafana_null_zero.png" max-width="1200px" >}} diff --git a/docs/sources/troubleshooting/troubleshoot-queries.md b/docs/sources/troubleshooting/troubleshoot-queries.md new file mode 100644 index 0000000..aa02b41 --- /dev/null +++ b/docs/sources/troubleshooting/troubleshoot-queries.md @@ -0,0 +1,29 @@ ++++ +title = "Troubleshoot queries" +description = "Guide to troubleshooting Grafana queries" +keywords = ["grafana", "troubleshooting", "documentation", "guide", "queries"] +weight = 400 ++++ + +# Troubleshoot queries + +This page provides information to solve common dashboard problems. + +## I get different results when I rearrange my functions + +Function order is very important. Just like in math, the order that you place your functions can affect the result. + +## Inspect your query request and response + +The most common problems are related to the query and response from your data source. Even if it looks +like a bug or visualization issue in Grafana, it is almost always a problem with the data source query or +the data source response. Start by inspecting your panel query and response. + +For more information, refer to [Inspect a panel]({{< relref "../panels/inspect-panel.md" >}}). + +## My query is slow + +How many data points is your query returning? A query that returns lots of data points will be slow. Try this: +- In **Query options**, limit the **Max data points** returned. +- In **Query options**, increase the **Min interval** time. +- In your query, use a `group by` function. diff --git a/docs/sources/variables/_index.md b/docs/sources/variables/_index.md new file mode 100644 index 0000000..97d5828 --- /dev/null +++ b/docs/sources/variables/_index.md @@ -0,0 +1,46 @@ ++++ +title = "Templates and variables" +weight = 130 ++++ + +# Templates and variables + +A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. So when you change +the value, using the dropdown at the top of the dashboard, your panel's metric queries will change to reflect the new value. + +Variables allow you to create more interactive and dynamic dashboards. Instead of hard-coding things like server, application, +and sensor names in your metric queries, you can use variables in their place. Variables are displayed as dropdown lists at the top of +the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. +{{< docs-imagebox img="/img/docs/v50/variables_dashboard.png" >}} + +These can be especially useful for administrators who want to allow Grafana viewers to quickly adjust visualizations but do not want to give them full editing permissions. Grafana Viewers can use variables. + +Variables and templates also allow you to single-source dashboards. If you have multiple identical data sources or servers, you can make one dashboard and use variables to change what you are viewing. This simplifies maintenance and upkeep enormously. + +## Templates + +A _template_ is any query that contains a variable. + +For example, if you were administering a dashboard to monitor several servers, you _could_ make a dashboard for each server. Or you could create one dashboard and use panels with template queries like this one: + +``` +wmi_system_threads{instance=~"$server"} +``` + +Variable values are always synced to the URL using the syntax `var-=value`. + +## Examples of templates and variables + +To see variable and template examples, go to any of the dashboards listed in [Variable examples]({{< relref "variable-examples.md" >}}). + +Variables are listed in drop-down lists across the top of the screen. Select different variables to see how the visualizations change. + +To see variable settings, navigate to **Dashboard Settings > Variables**. Click a variable in the list to see its settings. + +Variables can be used in titles, descriptions, text panels, and queries. Queries with text that starts with `$` are templates. Not all panels will have template queries. + +## Variable best practices + +- Variable drop-down lists are displayed in the order they are listed in the variable list in Dashboard settings. +- Put the variables that you will change often at the top, so they will be shown first (far left on the dashboard). + diff --git a/docs/sources/variables/advanced-variable-format-options.md b/docs/sources/variables/advanced-variable-format-options.md new file mode 100644 index 0000000..0650e78 --- /dev/null +++ b/docs/sources/variables/advanced-variable-format-options.md @@ -0,0 +1,161 @@ ++++ +title = "Advanced variable format options" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +weight = 600 ++++ + +# Advanced variable format options + +The formatting of the variable interpolation depends on the data source, but there are some situations where you might want to change the default formatting. + +For example, the default for the MySql data source is to join multiple values as comma-separated with quotes: `'server01','server02'`. In some cases, you might want to have a comma-separated string without quotes: `server01,server02`. You can make that happen with advanced variable formatting options listed below. + +## General syntax + +Syntax: `${var_name:option}` + +Test the formatting options on the [Grafana Play site](https://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1). + +If any invalid formatting option is specified, then `glob` is the default/fallback option. + +An alternative syntax (that might be deprecated in the future) is `[[var_name:option]]`. + +## CSV + +Formats variables with multiple values as a comma-separated string. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:csv}' +Interpolation result: 'test1,test2' +``` + +## Distributed - OpenTSDB + +Formats variables with multiple values in custom format for OpenTSDB. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:distributed}' +Interpolation result: 'test1,servers=test2' +``` + +## Doublequote + +Formats single- and multi-valued variables into a comma-separated string, escapes `"` in each value by `\"` and quotes each value with `"`. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:doublequote}' +Interpolation result: '"test1","test2"' +``` + +## Glob - Graphite + +Formats variables with multiple values into a glob (for Graphite queries). + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:glob}' +Interpolation result: '{test1,test2}' +``` + +## JSON + +Formats variables with multiple values as a comma-separated string. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:json}' +Interpolation result: '["test1", "test2"]' +``` + +## Lucene - Elasticsearch + +Formats variables with multiple values in Lucene format for Elasticsearch. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:lucene}' +Interpolation result: '("test1" OR "test2")' +``` + +## Percentencode + +Formats single and multi valued variables for use in URL parameters. + +```bash +servers = ['foo()bar BAZ', 'test2'] +String to interpolate: '${servers:percentencode}' +Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2' +``` + +## Pipe + +Formats variables with multiple values into a pipe-separated string. + +```bash +servers = ['test1.', 'test2'] +String to interpolate: '${servers:pipe}' +Interpolation result: 'test1.|test2' +``` + +## Raw + +Turns off data source-specific formatting, such as single quotes in an SQL query. + +```bash +servers = ['test.1', 'test2'] +String to interpolate: '${var_name:raw}' +Interpolation result: 'test.1,test2' +``` + +## Regex + +Formats variables with multiple values into a regex string. + +```bash +servers = ['test1.', 'test2'] +String to interpolate: '${servers:regex}' +Interpolation result: '(test1\.|test2)' +``` + +## Singlequote + +Formats single- and multi-valued variables into a comma-separated string, escapes `'` in each value by `\'` and quotes each value with `'`. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:singlequote}' +Interpolation result: "'test1','test2'" +``` + +## Sqlstring + +Formats single- and multi-valued variables into a comma-separated string, escapes `'` in each value by `''` and quotes each value with `'`. + +```bash +servers = ["test'1", "test2"] +String to interpolate: '${servers:sqlstring}' +Interpolation result: "'test''1','test2'" +``` + +## Text + +Formats single- and multi-valued variables into their text representation. For a single variable it will just return the text representation. For multi-valued variables it will return the text representation combined with `+`. + +```bash +servers = ["test1", "test2"] +String to interpolate: '${servers:text}' +Interpolation result: "test1 + test2" +``` + +## Query parameters + +Formats single- and multi-valued variables into their query parameter representation. Example: `var-foo=value1&var-foo=value2` + +```bash +servers = ["test1", "test2"] +String to interpolate: '${servers:queryparam}' +Interpolation result: "var-servers=test1&var-servers=test2" +``` diff --git a/docs/sources/variables/filter-variables-with-regex.md b/docs/sources/variables/filter-variables-with-regex.md new file mode 100644 index 0000000..fb25635 --- /dev/null +++ b/docs/sources/variables/filter-variables-with-regex.md @@ -0,0 +1,111 @@ ++++ +title = "Filter variables with regex" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +weight = 700 ++++ + + +# Filter variables with regex + +Using the Regex Query option, you filter the list of options returned by the variable query or modify the options returned. + +This page shows how to use regex to filter/modify values in the variable dropdown. + +Using the Regex Query Option, you filter the list of options returned by the Variable query or modify the options returned. For more information, refer to the Mozilla guide on [Regular expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). + +Examples of filtering on the following list of options: + +```text +backend_01 +backend_02 +backend_03 +backend_04 +``` + +## Filter so that only the options that end with `01` or `02` are returned: + +Regex: + +```regex +/.*[01|02]/ +``` + +Result: + +```text +backend_01 +backend_02 +``` + +## Filter and modify the options using a regex capture group to return part of the text: + +Regex: + +```regex +/.*(01|02)/ +``` + +Result: + +```text +01 +02 +``` + +## Filter and modify - Prometheus Example + +List of options: + +```text +up{instance="demo.robustperception.io:9090",job="prometheus"} 1 1521630638000 +up{instance="demo.robustperception.io:9093",job="alertmanager"} 1 1521630638000 +up{instance="demo.robustperception.io:9100",job="node"} 1 1521630638000 +``` + +Regex: + +```regex +/.*instance="([^"]*).*/ +``` + +Result: + +```text +demo.robustperception.io:9090 +demo.robustperception.io:9093 +demo.robustperception.io:9100 +``` + +## Filter and modify using named text and value capture groups + +> **Note:** This feature is available in Grafana 7.4+. + +Using named capture groups, you can capture separate 'text' and 'value' parts from the options returned by the variable query. This allows the variable drop-down list to contain a friendly name for each value that can be selected. + +For example, when querying the `node_hwmon_chip_names` Prometheus metric, the `chip_name` is a lot friendlier that the `chip` value. So the following variable query result: + +```text +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_0",chip_name="enp216s0f0np0"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_1",chip_name="enp216s0f0np1"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_2",chip_name="enp216s0f0np2"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_3",chip_name="enp216s0f0np3"} 1 +``` + +Passed through the following Regex: + +```regex +/chip_name="(?[^"]+)|chip="(?[^"]+)/g +``` + +Would produce the following drop-down list: + +```text +Display Name Value +------------ ------------------------- +enp216s0f0np0 0000:d7:00_0_0000:d8:00_0 +enp216s0f0np1 0000:d7:00_0_0000:d8:00_1 +enp216s0f0np2 0000:d7:00_0_0000:d8:00_2 +enp216s0f0np3 0000:d7:00_0_0000:d8:00_3 +``` + +**Note:** Only `text` and `value` capture group names are supported. diff --git a/docs/sources/variables/formatting-multi-value-variables.md b/docs/sources/variables/formatting-multi-value-variables.md new file mode 100644 index 0000000..2ec4d9d --- /dev/null +++ b/docs/sources/variables/formatting-multi-value-variables.md @@ -0,0 +1,30 @@ ++++ +title = "Multi-value variables" +weight = 600 ++++ + +# Multi-value variables + +Interpolating a variable with multiple values selected is tricky as it is not straight forward how to format the multiple values into a string that is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to inform the templating interpolation engine what format to use for multiple values. + +> **Note:** The **Custom all value** option on the variable must be blank for Grafana to format all values into a single string. If leave it blank, then the Grafana concatenates (adds together) all the values in the query. Something like `value1,value2,value3`. If a custom `all` value is used, then instead the value will be something like `*` or `all`. + +## Multi-value variables with a Graphite data source + +Graphite uses glob expressions. A variable with multiple values would, in this case, be interpolated as `{host1,host2,host3}` if the current variable value was *host1*, *host2*, and *host3*. + +## Multi-value variables with a Prometheus or InfluxDB data source + +InfluxDB and Prometheus use regex expressions, so the same variable would be interpolated as `(host1|host2|host3)`. Every value would also be regex escaped. If not, a value with a regex control character would break the regex expression. + +## Multi-value variables with an Elastic data source + +Elasticsearch uses lucene query syntax, so the same variable would be formatted as `("host1" OR "host2" OR "host3")`. In this case, every value must be escaped so that the value only contains lucene control words and quotation marks. + +## Troubleshoot multi-value variables + +Automatic escaping and formatting can cause problems and it can be tricky to grasp the logic behind it. Especially for InfluxDB and Prometheus where the use of regex syntax requires that the variable is used in regex operator context. + +If you do not want Grafana to do this automatic regex escaping and formatting, then you must do one of the following: +- Turn off the **Multi-value** or **Include All option** options. +- Use the [raw variable format]({{< relref "advanced-variable-format-options.md#raw" >}}). diff --git a/docs/sources/variables/inspect-variable.md b/docs/sources/variables/inspect-variable.md new file mode 100644 index 0000000..ab421d7 --- /dev/null +++ b/docs/sources/variables/inspect-variable.md @@ -0,0 +1,22 @@ ++++ +title = "Inspect variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +aliases = ["/docs/grafana/latest/reference/templating"] +weight = 125 ++++ + +# Inspect variables and their dependencies + +The variables page lets you easily identify whether a variable is being referenced (or used) in other variables or dashboard. In addition, you can also [add]({{< relref "variable-types/_index.md" >}}) variables and [manage]({{< relref "manage-variable.md" >}}) existing variables from this page. + +> **Note:** This feature is available in Grafana 7.4 and later versions. + +![Variables list](/img/docs/variables-templates/variables-list-7-4.png) + +Any variable that is referenced or used has a green check mark next to it, while unreferenced variables have a orange caution icon next to them. + +![Variables list](/img/docs/variables-templates/variable-not-referenced-7-4.png) + +In addition, all referenced variables have a dependency icon next to the green check mark. You can click on the icon to view the dependency map. The dependency map can be moved. You can zoom in out with mouse wheel or track pad equivalent. + +![Variables list](/img/docs/variables-templates/dependancy-map-7-4.png) diff --git a/docs/sources/variables/manage-variable.md b/docs/sources/variables/manage-variable.md new file mode 100644 index 0000000..b25c3a0 --- /dev/null +++ b/docs/sources/variables/manage-variable.md @@ -0,0 +1,22 @@ ++++ +title = "Manage variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +aliases = ["/docs/grafana/latest/reference/templating"] +weight = 120 ++++ + +# Manage variables + +The variables page lets you [add]({{< relref "variable-types/_index.md" >}}) variables and manage existing variables. It also allows you to [inspect]({{< relref "inspect-variable.md" >}}) variables and identify whether a variable is being referenced (or used) in other variables or dashboard. + +## Move + +You can move a variable up or down the list using the up and down arrows respectively. + +## Clone + +To clone a variable, click the clone icon from the set of icons on the right. This creates a copy of the variable with the name of the original variable prefixed with `copy_of_`. + +## Delete + +To delete a variable, click the trash icon from the set of icons on the right. diff --git a/docs/sources/variables/repeat-panels-or-rows.md b/docs/sources/variables/repeat-panels-or-rows.md new file mode 100644 index 0000000..9ec9af9 --- /dev/null +++ b/docs/sources/variables/repeat-panels-or-rows.md @@ -0,0 +1,50 @@ ++++ +title = "Repeat panels or rows" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable", "repeat"] +weight = 800 ++++ + +# Repeat panels or rows + +Grafana lets you create dynamic dashboards using _template variables_. All variables in your queries expand to the current value of the variable before the query is sent to the database. Variables let you reuse a single dashboard for all your services. + +Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want +Grafana to dynamically create new panels or rows based on what values you have selected, you can use the _Repeat_ feature. + +## Grafana Play examples + +You can see examples in the following dashboards: + +- [Prometheus repeat](https://play.grafana.org/d/000000036/prometheus-repeat) +- [Repeated Rows Dashboard](https://play.grafana.org/d/000000153/repeat-rows) + +## Repeating panels + +If you have a variable with `Multi-value` or `Include all value` options enabled you can choose one panel and have Grafana repeat that panel +for every selected value. You find the _Repeat_ feature under the _General tab_ in panel edit mode. + +The `direction` controls how the panels will be arranged. + +By choosing `horizontal` the panels will be arranged side-by-side. Grafana will automatically adjust the width +of each repeated panel so that the whole row is filled. Currently, you cannot mix other panels on a row with a repeated +panel. + +Set `Max per row` to tell grafana how many panels per row you want at most. It defaults to _4_ if you don't set anything. + +By choosing `vertical` the panels will be arranged from top to bottom in a column. The width of the repeated panels will be the same as of the first panel (the original template) being repeated. + +Only make changes to the first panel (the original template). To have the changes take effect on all panels you need to trigger a dynamic dashboard re-build. +You can do this by either changing the variable value (that is the basis for the repeat) or reload the dashboard. + +> **Note:** Repeating panels require variables to have one or more items selected; you cannot repeat a panel zero times to hide it. + +## Repeating rows + +As seen above with the panels you can also repeat rows if you have variables set with `Multi-value` or +`Include all value` selection option. + +To enable this feature you need to first add a new _Row_ using the _Add Panel_ menu. Then by hovering the row title and +clicking on the cog button, you will access the `Row Options` configuration panel. You can then select the variable +you want to repeat the row for. + +It may be a good idea to use a variable in the row title as well. diff --git a/docs/sources/variables/syntax.md b/docs/sources/variables/syntax.md new file mode 100644 index 0000000..f1ffcf8 --- /dev/null +++ b/docs/sources/variables/syntax.md @@ -0,0 +1,24 @@ ++++ +title = "Variable syntax" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +aliases = ["/docs/grafana/latest/reference/templating"] +weight = 100 ++++ + +# Variable syntax + +Panel titles and metric queries can refer to variables using two different syntaxes: + +- `$varname` + This syntax is easy to read, but it does not allow you to use a variable in the middle of a word. + **Example:** apps.frontend.$server.requests.count +- `${var_name}` Use this syntax when you want to interpolate a variable in the middle of an expression. +- `${var_name:}` This format gives you more control over how Grafana interpolates values. Refer to [Advanced variable format options]({{< relref "advanced-variable-format-options.md" >}}) for more detail on all the formatting types. +- `[[varname]]` Do not use. Deprecated old syntax, will be removed in a future release. + +Before queries are sent to your data source the query is _interpolated_, meaning the variable is replaced with its current value. During +interpolation, the variable value might be _escaped_ in order to conform to the syntax of the query language and where it is used. +For example, a variable used in a regex expression in an InfluxDB or Prometheus query will be regex escaped. Read the data source specific +documentation topic for details on value escaping during interpolation. + +For advanced syntax to override data source default formatting, refer to [Advanced variable format options]({{< relref "advanced-variable-format-options.md" >}}). diff --git a/docs/sources/variables/variable-examples.md b/docs/sources/variables/variable-examples.md new file mode 100644 index 0000000..2ef6166 --- /dev/null +++ b/docs/sources/variables/variable-examples.md @@ -0,0 +1,20 @@ ++++ +title = "Variable examples" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +weight = 200 ++++ + +# Variable examples + +This page contains links to dashboards in Grafana Play with examples of template variables. + +- [Elasticsearch Metrics](https://play.grafana.org/d/000000014/elasticsearch-metrics?orgId=1) - Uses ad hoc filters, global variables, and a custom variable. +- [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1) - Uses query variables, chained query variables, an interval variable, and a repeated panel. +- [Influx DB Group By Variable](https://play.grafana.org/d/000000137/influxdb-group-by-variable?orgId=1) - Query variable, panel uses the variable results to group the metric data. +- [InfluxDB Raw Query Template Var](https://play.grafana.org/d/000000083/influxdb-raw-query-template-var?orgId=1) - Uses query variables, chained query variables, and an interval variable. +- [InfluxDB Server Monitoring](https://play.grafana.org/d/AAy9r_bmk/influxdb-server-monitoring?orgId=1) - Uses query variables, chained query variables, an interval variable, and an ad hoc filter. +- [Prometheus templating](https://play.grafana.org/d/000000063/prometheus-templating?orgId=1) - Uses chained query variables. +- [Template Redux](https://play.grafana.org/d/p-k6QtkGz/template-redux?orgId=1) - Uses query variables, chained query variables, ad hoc filters, an interval variable, a text box variable, a custom variable, and a data source variable. +- [Templating, repeated panels](https://play.grafana.org/d/000000025/templating-repeated-panels?orgId=1) - Two sets of repeated panels use query variables. +- [Templating showcase](https://play.grafana.org/d/000000091/templating-showcase?orgId=1) - Uses custom, query, chained query, and data source variables. +- [Templating value groups](https://play.grafana.org/d/000000024/templating-value-groups?orgId=1) - Uses query variable with value groups. diff --git a/docs/sources/variables/variable-selection-options.md b/docs/sources/variables/variable-selection-options.md new file mode 100644 index 0000000..24ce424 --- /dev/null +++ b/docs/sources/variables/variable-selection-options.md @@ -0,0 +1,26 @@ ++++ +title = "Variable selection options" +weight = 400 ++++ + +# Configure variable selection options + +**Selection Options** are a feature you can use to manage variable option selections. All selection options are optional, and they are off by default. + +## Multi-value + +If you turn this on, then the variable dropdown list allows users to select multiple options at the same time. For more information, refer to [Formatting multi-value variables]({{< relref "formatting-multi-value-variables.md" >}}). + +## Include All option + +Grafana adds an `All` option to the variable dropdown list. If a user selects this option, then all variable options are selected. + +## Custom all value + +This option is only visible if the **Include All option** is selected. + +Enter regex, globs, or lucene syntax in the **Custom all value** field to define the value of the `All` option. + +By default the `All` value includes all options in combined expression. This can become very long and can have performance problems. Sometimes it can be better to specify a custom all value, like a wildcard regex. + +In order to have custom regex, globs, or lucene syntax in the **Custom all value** option, it is never escaped so you will have to think about what is a valid value for your data source. diff --git a/docs/sources/variables/variable-types/_index.md b/docs/sources/variables/variable-types/_index.md new file mode 100644 index 0000000..712bfec --- /dev/null +++ b/docs/sources/variables/variable-types/_index.md @@ -0,0 +1,20 @@ ++++ +title = "Add variables" +weight = 140 ++++ + +# Variables types + +Grafana uses several types of variables. + +| Variable type | Description | +|:---|:---| +| Query | Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. [Add a query variable]({{< relref "add-query-variable.md" >}}). | +| Custom | Define the variable options manually using a comma-separated list. [Add a custom variable]({{< relref "add-custom-variable.md" >}}). | +| Text box | Display a free text input field with an optional default value. [Add a text box variable]({{< relref "add-text-box-variable.md" >}}). | +| Constant | Define a hidden constant. [Add a constant variable]({{< relref "add-constant-variable.md" >}}). | +| Data source | Quickly change the data source for an entire dashboard. [Add a data source variable]({{< relref "add-data-source-variable.md" >}}). | +| Interval | Interval variables represent time spans. [Add an interval variable]({{< relref "add-interval-variable.md" >}}). | +| Ad hoc filters | Key/value filters that are automatically added to all metric queries for a data source (InfluxDB, Prometheus, and Elasticsearch only). [Add ad hoc filters]({{< relref "add-ad-hoc-filters.md" >}}). | +| Global variables | Built-in variables that can be used in expressions in the query editor. Refer to [Global variables]({{< relref "global-variables" >}}). | +| Chained variables | Variable queries can contain other variables. Refer to [Chained variables]({{< relref "chained-variables.md" >}}). | diff --git a/docs/sources/variables/variable-types/add-ad-hoc-filters.md b/docs/sources/variables/variable-types/add-ad-hoc-filters.md new file mode 100644 index 0000000..074c494 --- /dev/null +++ b/docs/sources/variables/variable-types/add-ad-hoc-filters.md @@ -0,0 +1,32 @@ ++++ +title = "Add ad hoc filters" +aliases = ["/docs/grafana/latest/variables/add-ad-hoc-filters.md"] +weight = 700 ++++ + +# Add ad hoc filters + +_Ad hoc filters_ allow you to add key/value filters that are automatically added to all metric queries that use the specified data source. Unlike other variables, you do not use ad hoc filters in queries. Instead, you use ad hoc filters to write filters for existing queries. + +> **Note:** Ad hoc filter variables only work with InfluxDB, Prometheus, and Elasticsearch data sources. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Ad hoc filters**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Options + +1. In the **Data source** list, select the target data source. For more information about data sources, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source.md" >}}). +1. Click **Add** to add the variable to the dashboard. + +## Create ad hoc filters + +Ad hoc filters are one of the most complex and flexible variable options available. Instead of a regular list of variable options, this variable allows you to build a dashboard-wide ad hoc query. Filters you apply in this manner are applied to all panels on the dashboard. \ No newline at end of file diff --git a/docs/sources/variables/variable-types/add-constant-variable.md b/docs/sources/variables/variable-types/add-constant-variable.md new file mode 100644 index 0000000..68c6815 --- /dev/null +++ b/docs/sources/variables/variable-types/add-constant-variable.md @@ -0,0 +1,31 @@ ++++ +title = "Add a constant variable" +aliases = ["/docs/grafana/latest/variables/add-constant-variable.md"] +weight = 400 ++++ + +# Add a constant variable + +_Constant_ variables allow you to define a hidden constant. This is useful for metric path prefixes for dashboards you want to share. When you export a dashboard, constant variables are converted to import options. + +Constant variables are _not_ flexible. Each constant variable only holds one value, and it cannot be updated unless you update the variable settings. + +Constant variables are useful when you have complex values that you need to include in queries but don't want to retype in every single query. For example, if you had a server path called `i-0b6a61efe2ab843gg`, then you could replace it with a variable called `$path_gg`. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Constant**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **Variable -** No variable dropdown is displayed on the dashboard. This is the default. + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + +## Enter Constant options + +1. In the **Value** field, enter the variable value. You can enter letters, numbers, and symbols. You can even use wildcards if you use [raw format]({{< relref "../advanced-variable-format-options.md#raw" >}}). +1. In **Preview of values**, Grafana displays the current variable value. Review it to ensure it matches what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-custom-variable.md b/docs/sources/variables/variable-types/add-custom-variable.md new file mode 100644 index 0000000..d6eb462 --- /dev/null +++ b/docs/sources/variables/variable-types/add-custom-variable.md @@ -0,0 +1,30 @@ ++++ +title = "Add a custom variable" +aliases = ["/docs/grafana/latest/variables/add-custom-variable.md"] +weight = 200 ++++ + +# Add a custom variable + +Use a _custom_ variable for values that do not change. This might be numbers, strings, or even other variables. + +For example, if you have server names or region names that never change, then you might want to create them as custom variables rather than query variables. Because they do not change, you might use them in [chained variables]({{< relref "chained-variables.md" >}}) rather than other query variables. That would reduce the number of queries Grafana must send when chained variables are updated. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Custom**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Custom Options + +1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, other variables or key/value pairs separated by a space and a colon, i.e. `key1 : value1,key2 : value2`. +1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-data-source-variable.md b/docs/sources/variables/variable-types/add-data-source-variable.md new file mode 100644 index 0000000..941999e --- /dev/null +++ b/docs/sources/variables/variable-types/add-data-source-variable.md @@ -0,0 +1,29 @@ ++++ +title = "Add a data source variable" +aliases = ["/docs/grafana/latest/variables/add-data-source-variable.md"] +weight = 500 ++++ + +# Add a data source variable + +_Data source_ variables allow you to quickly change the data source for an entire dashboard. They are useful if you have multiple instances of a data source, perhaps in different environments. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Datasource**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Data source options + +1. In the **Type** list, select the target data source for the variable. For more information about data sources, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source.md" >}}). +1. (optional) In **Instance name filter**, enter a regex filter for which data source instances to choose from in the variable value drop-down list. Leave this field empty to display all instances. +1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-interval-variable.md b/docs/sources/variables/variable-types/add-interval-variable.md new file mode 100644 index 0000000..ca7b7f3 --- /dev/null +++ b/docs/sources/variables/variable-types/add-interval-variable.md @@ -0,0 +1,46 @@ ++++ +title = "Add an interval variable" +aliases = ["/docs/grafana/latest/variables/add-interval-variable.md"] +weight = 600 ++++ + +# Add an interval variable + +Use an _interval_ variable to represents time spans such as `1m`,`1h`, `1d`. You can think of them as a dashboard-wide "group by time" command. Interval variables change how the data is grouped in the visualization. You can also use the Auto Option to return a set number of data points per time span. + +You can use an interval variable as a parameter to group by time (for InfluxDB), date histogram interval (for Elasticsearch), or as a summarize function parameter (for Graphite). + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Interval**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Interval Options + +1. In the **Values** field, enter the time range intervals that you want to appear in the variable drop-down list. The following time units are supported: `s (seconds)`, `m (minutes)`, `h (hours)`, `d (days)`, `w (weeks)`, `M (months)`, and `y (years)`. You can also accept or edit the default values: `1m,10m,30m,1h,6h,12h,1d,7d,14d,30d`. +1. (optional) Turn on the **Auto Option** if you want to add the `auto` option to the list. This option allows you to specify how many times the current time range should be divided to calculate the current `auto` time span. If you turn it on, then two more options appear: + - **Step count -** Select the number of times the current time range will be divided to calculate the value, similar to the **Max data points** query option. For example, if the current visible time range is 30 minutes, then the `auto` interval groups the data into 30 one-minute increments. The default value is 30 steps. + - **Min Interval -** The minimum threshold below which the step count intervals will not divide the time. To continue the 30 minute example, if the minimum interval is set to 2m, then Grafana would group the data into 15 two-minute increments. +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. + +## Interval variable examples + +Example using the template variable `myinterval` in a Graphite function: + +``` +summarize($myinterval, sum, false) +``` + +A more complex Graphite example, from the [Graphite Template Nested Requests panel](https://play.grafana.org/d/000000056/graphite-templated-nested?editPanel=2&orgId=1): + +``` +groupByNode(summarize(movingAverage(apps.$app.$server.counters.requests.count, 5), '$interval', 'sum', false), 2, 'sum') +``` \ No newline at end of file diff --git a/docs/sources/variables/variable-types/add-query-variable.md b/docs/sources/variables/variable-types/add-query-variable.md new file mode 100644 index 0000000..47f7a17 --- /dev/null +++ b/docs/sources/variables/variable-types/add-query-variable.md @@ -0,0 +1,42 @@ ++++ +title = "Add a query variable" +aliases = ["/docs/grafana/latest/variables/add-query-variable.md"] +weight = 100 ++++ + +# Add a query variable + +Query variables allow you to write a data source query that can return a list of metric names, tag values, or keys. For example, a query variable might return a list of server names, sensor IDs, or data centers. The variable values change as they dynamically fetch options with a data source query. + +Query expressions can contain references to other variables and in effect create linked variables. Grafana detects this and automatically refreshes a variable when one of its linked variables change. + +## Query expressions + +Query expressions are different for each data source. For more information, refer to the documentation for your [data source]({{< relref "../../datasources/_index.md" >}}). + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Query**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Query Options + +1. In the **Data source** list, select the target data source for the query. For more information about data sources, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source.md" >}}). +1. In the **Refresh** list, select when the variable should update options. + - **On Dashboard Load -** Queries the data source every time the dashboard loads. This slows down dashboard loading, because the variable query needs to be completed before dashboard can be initialized. + - **On Time Range Change -** Queries the data source when the dashboard time range changes. Only use this option if your variable options query contains a time range filter or is dependent on the dashboard time range. +1. In the **Query** field, enter a query. + - The query field varies according to your data source. Some data sources have custom query editors. + - If you need more room in a single input field query editor, then hover your cursor over the lines in the lower right corner of the field and drag downward to expand. +1. (optional) In the **Regex** field, type a regex expression to filter or capture specific parts of the names returned by your data source query. To see examples, refer to [Filter variables with regex]({{< relref "../filter-variables-with-regex.md" >}}). +1. In the **Sort** list, select the sort order for values to be displayed in the dropdown list. The default option, **Disabled**, means that the order of options returned by your data source query will be used. +1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-text-box-variable.md b/docs/sources/variables/variable-types/add-text-box-variable.md new file mode 100644 index 0000000..ad35046 --- /dev/null +++ b/docs/sources/variables/variable-types/add-text-box-variable.md @@ -0,0 +1,27 @@ ++++ +title = "Add a text box variable" +aliases = ["/docs/grafana/latest/variables/add-text-box-variable.md"] +weight = 300 ++++ + +# Add a text box variable + +_Text box_ variables display a free text input field with an optional default value. This is the most flexible variable, because you can enter any value. Use this type of variable if you have metrics with high cardinality or if you want to update multiple panels in a dashboard at the same time. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Text box**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Text options + +1. (optional) In the **Default value** field, select the default value for the variable. If you do not enter anything in this field, then Grafana displays an empty text box for users to type text into. +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/chained-variables.md b/docs/sources/variables/variable-types/chained-variables.md new file mode 100644 index 0000000..e94091d --- /dev/null +++ b/docs/sources/variables/variable-types/chained-variables.md @@ -0,0 +1,173 @@ ++++ +title = "Chained variables" +keywords = ["grafana", "templating", "variable", "nested", "chained", "linked"] +aliases = ["/docs/grafana/latest/variables/chained-variables.md"] +weight = 800 ++++ + +# Chained variables + +_Chained variables_, also called _linked variables_ or _nested variables_, are query variables with one or more other variables in their variable query. This page explains how chained variables work and provides links to example dashboards that use chained variables. + +Chained variable queries are different for every data source, but the premise is the same for all. You can use chained variable queries in any data source that allows them. + +Extremely complex linked templated dashboards are possible, 5 or 10 levels deep. Technically, there is no limit to how deep or complex you can go, but the more links you have, the greater the query load. + +## Grafana Play dashboard examples + +The following Grafana Play dashboards contain fairly simple chained variables, only two layers deep. To view the variables and their settings, click **Dashboard settings** (gear icon) and then click **Variables**. Both examples are expanded in the following section. + +- [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1&var-app=country&var-server=All&var-interval=1h) +- [InfluxDB Templated](https://play.grafana.org/d/000000002/influxdb-templated?orgId=1) + +## Examples explained + +Variables are useful to reuse dashboards, dynamically change what is shown in dashboards. Chained variables are especially useful to filter what you see. + +Create parent/child relationship in variable, sort of a tree structure where you can select different levels of filters. + +The following sections explain the linked examples in the dashboards above in depth and builds on them. While the examples are data source-specific, the concepts can be applied broadly. + +### Graphite example + +In this example, you have several applications. Each application has a different subset of servers. It is based on the [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1&var-app=country&var-server=All&var-interval=1h). + +Now, you could make separate variables for each metric source, but then you have to know which server goes with which app. A better solution is to use one variable to filter another. In this example, when the user changes the value of the `app` variable, it changes the dropdown options returned by the `server` variable. Both variables use the **Multi-value** option and **Include all option**, allowing users to select some or all options presented at any time. + +#### app variable + +The query for this variable basically says, "Give me all the applications that exist." + +``` +apps.* +``` + +The values returned are `backend`, `country`, `fakesite`, and `All`. + +#### server variable + +The query for this variable basically says, "Give me all servers for the currently chosen application." + +``` +apps.$app.* +``` + +If the user selects `backend`, then the query changes to: + +``` +apps.backend.* +``` + +The query returns all servers associated with `backend`, including `backend_01`, `backend_02`, and so on. + +If the user selects `fakesite`, then the query changes to: + +``` +apps.fakesite.* +``` + +The query returns all servers associated with `fakesite`, including `web_server_01`, `web_server_02`, and so on. + +#### More variables + +> **Note:** This example is theoretical. The Graphite server used in the example does not contain CPU metrics. + +The dashboard stops at two levels, but you could keep going. For example, if you wanted to get CPU metrics for selected servers, you could copy the `server` variable and extend the query so that it reads: + +``` +apps.$app.$server.cpu.* +``` + +This query basically says, "Show me the CPU metrics for the selected server." + +Depending on what variable options the user selects, you could get queries like: + +``` +apps.backend.backend_01.cpu.* +apps.{backend.backend_02,backend_03}.cpu.* +apps.fakesite.web_server_01.cpu.* +``` + +### InfluxDB example + +In this example, you have several data centers. Each data center has a different subset of hosts. It is based on the [InfluxDB Templated](https://play.grafana.org/d/000000002/influxdb-templated?orgId=1). + +In this example, when the user changes the value of the `datacenter` variable, it changes the dropdown options returned by the `host` variable. The `host` variable uses the **Multi-value** option and **Include all option**, allowing users to select some or all options presented at any time. The `datacenter` does not use either option, so you can only select one data center at a time. + +#### datacenter variable + +The query for this variable basically says, "Give me all the data centers that exist." + +``` +SHOW TAG VALUES WITH KEY = "datacenter" +``` + +The values returned are `America`, `Africa`, `Asia`, and `Europe`. + +#### host variable + +The query for this variable basically says, "Give me all hosts for the currently chosen data center." + +``` +SHOW TAG VALUES WITH KEY = "hostname" WHERE "datacenter" =~ /^$datacenter$/ +``` + +If the user selects `America`, then the query changes to: + +``` +SHOW TAG VALUES WITH KEY = "hostname" WHERE "datacenter" =~ /^America/ +``` + +The query returns all servers associated with `America`, including `server1`, `server2`, and so on. + +If the user selects `Europe`, then the query changes to: + +``` +SHOW TAG VALUES WITH KEY = "hostname" WHERE "datacenter" =~ /^Europe/ +``` + +The query returns all servers associated with `Europe`, including `server3`, `server4`, and so on. + +#### More variables + +> **Note:** This example is theoretical. The InfluxDB server used in the example does not contain CPU metrics. + +The dashboard stops at two levels, but you could keep going. For example, if you wanted to get CPU metrics for selected hosts, you could copy the `host` variable and extend the query so that it reads: + +``` +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^$datacenter$/ AND "host" =~ /^$host$/ +``` + +This query basically says, "Show me the CPU metrics for the selected host." + +Depending on what variable options the user selects, you could get queries like: + +```bash +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^America/ AND "host" =~ /^server2/ +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^Africa/ AND "host" =~ /^server/7/ +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^Europe/ AND "host" =~ /^server3+server4/ +``` + +## Best practices and tips + +The following practices will make your dashboards and variables easier to use. + +### Creating new linked variables + +- Chaining variables create parent/child dependencies. You can envision them as a ladder or a tree. +- The easiest way to create a new chained variable is to copy the variable that you want to base the new one on. In the variable list, click the **Duplicate variable** icon to the right of the variable entry to create a copy. You can then add on to the query for the parent variable. +- New variables created this way appear at the bottom of the list. You might need to drag it to a different position in the list to get it into a logical order. + +### Variable order + +You can change the orders of variables in the dashboard variable list by clicking the up and down arrows on the right side of each entry. Grafana lists variable dropdowns left to right according to this list, with the variable at the top on the far left. + +- List variables that do not have dependencies at the top, before their child variables. +- Each variable should follow the one it is dependent on. +- Remember there is no indication in the UI of which variables have dependency relationships. List the variables in a logical order to make it easy on other users (and yourself). + +### Complexity consideration + +The more layers of dependency you have in variables, the longer it will take to update dashboards after you change variables. + +For example, if you have a series of four linked variables (country, region, server, metric) and you change a root variable value (country), then Grafana must run queries for all the dependent variables before it updates the visualizations in the dashboard. diff --git a/docs/sources/variables/variable-types/global-variables.md b/docs/sources/variables/variable-types/global-variables.md new file mode 100644 index 0000000..c81e27d --- /dev/null +++ b/docs/sources/variables/variable-types/global-variables.md @@ -0,0 +1,84 @@ ++++ +title = "Global variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable", "global", "standard"] +aliases = ["/docs/grafana/latest/variables/global-variables.md"] +weight = 900 ++++ + +# Global variables + +Grafana has global built-in variables that can be used in expressions in the query editor. This topic lists them in alphabetical order and defines them. These variables are useful in queries, dashboard links, panel links, and data links. + +## $__dashboard + +> Only available in Grafana v6.7+. In Grafana 7.1, the variable changed from showing the UID of the current dashboard to the name of the current dashboard. + +This variable is the name of the current dashboard. + +## $__from and $__to + +Grafana has two built in time range variables: `$__from` and `$__to`. They are currently always interpolated as epoch milliseconds by default but you can control date formatting. + +> This special formatting syntax is only available in Grafan a 7.1.2+ + +| Syntax | Example result | Description | +| ------------------------ | ------------------------ | ----------- | +| `${__from}` | 1594671549254 | Unix millisecond epoch | +| `${__from:date}` | 2020-07-13T20:19:09.254Z | No args, defaults to ISO 8601/RFC 3339 | +| `${__from:date:iso}` | 2020-07-13T20:19:09.254Z | ISO 8601/RFC 3339 | +| `${__from:date:seconds}` | 1594671549 | Unix seconds epoch | +| `${__from:date:YYYY-MM}` | 2020-07 | Any custom [date format](https://momentjs.com/docs/#/displaying/) that does not include the `:` character | + +The above syntax works with `${__to}` as well. + +You can use this variable in URLs as well. For example, send a user to a dashboard that shows a time range from six hours ago until now: https://play.grafana.org/d/000000012/grafana-play-home?viewPanel=2&orgId=1?from=now-6h&to=now + +## $__interval + +You can use the `$__interval` variable as a parameter to group by time (for InfluxDB, MySQL, Postgres, MSSQL), Date histogram interval (for Elasticsearch), or as a _summarize_ function parameter (for Graphite). + +Grafana automatically calculates an interval that can be used to group by time in queries. When there are more data points than can be shown on a graph then queries can be made more efficient by grouping by a larger interval. It is more efficient to group by 1 day than by 10s when looking at 3 months of data and the graph will look the same and the query will be faster. The `$__interval` is calculated using the time range and the width of the graph (the number of pixels). + +Approximate Calculation: `(from - to) / resolution` + +For example, when the time range is 1 hour and the graph is full screen, then the interval might be calculated to `2m` - points are grouped in 2 minute intervals. If the time range is 6 months and the graph is full screen, then the interval might be `1d` (1 day) - points are grouped by day. + +In the InfluxDB data source, the legacy variable `$interval` is the same variable. `$__interval` should be used instead. + +The InfluxDB and Elasticsearch data sources have `Group by time interval` fields that are used to hard code the interval or to set the minimum limit for the `$__interval` variable (by using the `>` syntax -> `>10m`). + +## $__interval_ms + +This variable is the `$__interval` variable in milliseconds, not a time interval formatted string. For example, if the `$__interval` is `20m` then the `$__interval_ms` is `1200000`. + +## $__name + +This variable is only available in the Singlestat panel and can be used in the prefix or suffix fields on the Options tab. The variable will be replaced with the series name or alias. + +## $__org + +This variable is the ID of the current organization. +`${__org.name}` is the name of the current organization. + +## $__user + +> Only available in Grafana v7.1+ + +`${__user.id}` is the ID of the current user. +`${__user.login}` is the login handle of the current user. +`${__user.email}` is the email for the current user. + +## $__range + +Currently only supported for Prometheus data sources. This variable represents the range for the current dashboard. It is calculated by `to - from`. It has a millisecond and a second representation called `$__range_ms` and `$__range_s`. + +## $timeFilter or $__timeFilter + +The `$timeFilter` variable returns the currently selected time range as an expression. For example, the time range interval `Last 7 days` expression is `time > now() - 7d`. + +This is used in several places, including: + +- The WHERE clause for the InfluxDB data source. Grafana adds it automatically to InfluxDB queries when in Query Editor mode. You can add it manually in Text Editor mode: `WHERE $timeFilter`. +- Log Analytics queries in the Azure Monitor data source. +- SQL queries in MySQL, Postgres, and MSSQL. +- The `$__timeFilter` variable is used in the MySQL data source. diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md new file mode 100644 index 0000000..9d9b064 --- /dev/null +++ b/docs/sources/whatsnew/_index.md @@ -0,0 +1,40 @@ ++++ +title = "What's new" +aliases = ["/docs/grafana/latest/guides/"] +weight = 1 ++++ + +# What's new Grafana + +Grafana is changing all the time. For release highlights checkout links below, if you want a complete list of every change, as well +as info on deprecations, breaking changes and plugin development read the [release notes]({{< relref "../release-notes" >}}). + +## Grafana 8 + +- [What's new in 8.0]({{< relref "whats-new-in-v8-0" >}}) + +## Grafana 7 + +- [What's new in 7.5]({{< relref "whats-new-in-v7-5" >}}) +- [What's new in 7.4]({{< relref "whats-new-in-v7-4" >}}) +- [What's new in 7.3]({{< relref "whats-new-in-v7-3" >}}) +- [What's new in 7.2]({{< relref "whats-new-in-v7-2" >}}) +- [What's new in 7.1]({{< relref "whats-new-in-v7-1" >}}) +- [What's new in 7.0]({{< relref "whats-new-in-v7-0" >}}) + +## Grafana 6 +- [What's new in 6.7]({{< relref "whats-new-in-v6-7" >}}) +- [What's new in 6.6]({{< relref "whats-new-in-v6-6" >}}) +- [What's new in 6.5]({{< relref "whats-new-in-v6-5" >}}) +- [What's new in 6.4]({{< relref "whats-new-in-v6-4" >}}) +- [What's new in 6.3]({{< relref "whats-new-in-v6-3" >}}) +- [What's new in 6.2]({{< relref "whats-new-in-v6-2" >}}) +- [What's new in 6.1]({{< relref "whats-new-in-v6-1" >}}) +- [What's new in 6.0]({{< relref "whats-new-in-v6-0" >}}) + +## Grafana 5 +- [What's new in 5.4]({{< relref "whats-new-in-v5-4" >}}) +- [What's new in 5.3]({{< relref "whats-new-in-v5-3" >}}) +- [What's new in 5.2]({{< relref "whats-new-in-v5-2" >}}) +- [What's new in 5.1]({{< relref "whats-new-in-v5-1" >}}) +- [What's new in 5.0]({{< relref "whats-new-in-v5-0" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v2-0.md b/docs/sources/whatsnew/whats-new-in-v2-0.md new file mode 100644 index 0000000..7c037b7 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-0.md @@ -0,0 +1,178 @@ ++++ +title = "What's new in Grafana v2.0" +description = "Feature and improvement highlights for Grafana v2.0" +keywords = ["grafana", "new", "documentation", "2.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2/"] +weight = -1 +[_build] +list = false ++++ + +# What's new in Grafana v2.0 + +Grafana 2.0 represents months of work by the Grafana team and the community. We are pleased to be able to +release the Grafana 2.0 beta. This is a guide that describes some of changes and new features that can +be found in Grafana V2.0. + +If you are interested in how to migrate from Grafana V1.x to V2.0, please read our [Migration Guide](../installation/migrating_to2.md) + +## New backend server + +Grafana now ships with its own required backend server. Also completely open-source, it's written in Go and has a full HTTP API. + +In addition to new features, the backend server makes it much easier to set up and enjoy Grafana. Grafana 2.0 now ships as cross platform binaries with no dependencies. Authentication is built in, and Grafana is now capable of proxying connections to Data Sources. There are no longer any CORS (Cross Origin Resource Sharing) issues requiring messy workarounds. Elasticsearch is no longer required just to store dashboards. + +## User and Organization permissions + +All Dashboards and Data Sources are linked to an Organization (not to a User). Users are linked to +Organizations via a role. That role can be: + +- `Viewer`: Can only view dashboards, not save / create them. +- `Editor`: Can view, update and create dashboards. +- `Admin`: Everything an Editor can plus edit and add data sources and organization users. + +> **Note:** A `Viewer` can still view all metrics exposed through a data source, not only +> the metrics used in already existing dashboards. That is because there are not +> per series permissions in Graphite, InfluxDB or OpenTSDB. + +There are currently no permissions on individual dashboards. + +Read more about Grafana's new user model on the [Admin section](../reference/admin/) + +## Dashboard Snapshot sharing + +A Dashboard Snapshot is an easy way to create and share a URL for a stripped down, point-in-time version of any Dashboard. +You can give this URL to anyone or everyone, and they can view the Snapshot even if they're not a User of your Grafana instance. + +You can set an expiration time for any Snapshots you create. When you create a Snapshot, we strip sensitive data, like +panel metric queries, annotation and template queries and panel links. The data points displayed on +screen for that specific time period in your Dashboard is saved in the JSON of the Snapshot itself. + +Sharing a Snapshot is similar to sharing a link to a screenshot of your dashboard, only way better (they'll look great at any screen resolution, you can hover over series, +even zoom in). Also they are fast to load as they aren't actually connected to any live Data Sources in any way. + +They're a great way to communicate about a particular incident with specific people who aren't users of your Grafana instance. You can also use them to show off your dashboards over the Internet. + +![](/img/docs/v2/dashboard_snapshot_dialog.png) + +### Publish snapshots + +You can publish snapshots locally or to [snapshot.raintank.io](http://snapshot.raintank.io). snapshot.raintank is a free service provided by [raintank](http://raintank.io) for hosting external Grafana snapshots. + +Either way, anyone with the link (and access to your Grafana instance for local snapshots) can view it. + +## Panel time overrides and timeshift + +In Grafana v2.x you can now override the relative time range for individual panels, causing them to be different than what is selected in the Dashboard time picker in the upper right. You can also add a time shift to individual panels. This allows you to show metrics from different time periods or days at the same time. + +![](/img/docs/v2/panel_time_override.jpg) + +You control these overrides in panel editor mode and the new tab `Time Range`. + +![](/img/docs/v2/time_range_tab.jpg) + +When you zoom or change the Dashboard time to a custom absolute time range, all panel overrides will be disabled. The panel relative time override is only active when the dashboard time is also relative. The panel timeshift override however is always active, even when the dashboard time is absolute. + +The `Hide time override info` option allows you to hide the override info text that is by default shown in the +upper right of a panel when overridden time range options. + +Currently you can only override the dashboard time with relative time ranges, not absolute time ranges. + +## Panel iframe embedding + +You can embed a single panel on another web page or your own application using the panel share dialog. + +Below you should see an iframe with a graph panel (taken from a Dashboard snapshot at [snapshot.raintank.io](http://snapshot.raintank.io). + +Try hovering or zooming on the panel below! + + + +This feature makes it easy to include interactive visualizations from your Grafana instance anywhere you want. + +## New dashboard top header + +The top header has gotten a major streamlining in Grafana V2.0. + + + +1. `Side menubar toggle` Toggle the side menubar on or off. This allows you to focus on the data presented on the Dashboard. The side menubar provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources. +1. `Dashboard dropdown` The main dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage the Playlist. +1. `Star Dashboard`: Star (or un-star) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. +1. `Share Dashboard`: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing. +1. `Save dashboard`: Save the current Dashboard with the current name. +1. `Settings`: Manage Dashboard settings and features such as Templating, Annotations and the name. + +> **Note:** In Grafana v2.0 when you change the title of a dashboard and then save it, it will no +> longer create a new Dashboard. It will just change the name for the current Dashboard. +> To change name and create a new Dashboard use the `Save As...` menu option + +### New Side menubar + +The new side menubar provides access to features such as User Preferences, Organizations, and Data Sources. + +If you have multiple Organizations, you can easily switch between them here. + +The side menubar will become more useful as we build out additional functionality in Grafana 2.x + +You can easily collapse or re-open the side menubar at any time by clicking the Grafana icon in the top left. We never want to get in the way of the data. + +## New search view and starring dashboards + +![](/img/docs/v2/dashboard_search.jpg) + +The dashboard search view has gotten a big overhaul. You can now see and filter by which dashboard you have personally starred. + +## Logarithmic scale + +The Graph panel now supports 3 logarithmic scales, `log base 10`, `log base 32`, `log base 1024`. Logarithmic y-axis scales are very useful when rendering many series of different order of magnitude on the same scale (eg. +latency, network traffic, and storage) + +![](/img/docs/v2/graph_logbase10_ms.png) + +## Dashlist panel + +![](/img/docs/v2/dashlist_starred.png) + +The dashlist is a new panel in Grafana v2.0. It allows you to show your personal starred dashboards, as well as do custom searches based on search strings or tags. + +dashlist is used on the new Grafana Home screen. It is included as a reference Panel and is useful to provide basic linking between Dashboards. + +## Data Source proxy and admin views + +Data sources in Grafana v2.0 are no longer defined in a config file. Instead, they are added through the UI or the HTTP API. + +The backend can now proxy data from Data Sources, which means that it is a lot easier to get started using Grafana with Graphite or OpenTSDB without having to spend time with CORS (Cross origin resource sharing) work-arounds. + +In addition, connections to Data Sources can be better controlled and secured, and authentication information no longer needs to be exposed to the browser. + +## Dashboard "now delay" + +A commonly reported problem has been graphs dipping to zero at the end, because metric data for the last interval has yet to be written to the Data Source. These graphs then "self correct" once the data comes in, but can look deceiving or alarming at times. + +You can avoid this problem by adding a `now delay` in `Dashboard Settings` > `Time Picker` tab. This new feature will cause Grafana to ignore the most recent data up to the set delay. +![](/img/docs/v2/timepicker_now_delay.jpg) + +The delay that may be necessary depends on how much latency you have in your collection pipeline. + +## Dashboard overwrite protection + +Grafana v2.0 protects Users from accidentally overwriting each others Dashboard changes. Similar protections are in place if you try to create a new Dashboard with the same name as an existing one. + +![](/img/docs/v2/overwrite_protection.jpg) + +These protections are only the first step; we will be building out additional capabilities around dashboard versioning and management in future versions of Grafana. + +## User preferences + +If you open side menu (by clicking on the Grafana icon in the top header) you can access your Profile Page. + +Here you can update your user details, UI Theme, and change your password. + +## Server-side Panel rendering + +Grafana now supports server-side PNG rendering. From the Panel share dialog you now have access to a link that will render a particular Panel to a PNG image. + +> **Note:** This requires that your Data Source is accessible from your Grafana instance. + +![](/img/docs/v2/share_dialog_image_highlight.jpg) diff --git a/docs/sources/whatsnew/whats-new-in-v2-1.md b/docs/sources/whatsnew/whats-new-in-v2-1.md new file mode 100644 index 0000000..b4699b5 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-1.md @@ -0,0 +1,133 @@ ++++ +title = "What's new in Grafana v2.1" +description = "Feature and improvement highlights for Grafana v2.1" +keywords = ["grafana", "new", "documentation", "2.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2-1/"] +weight = -2 +[_build] +list = false ++++ + +# What's new in Grafana v2.1 +Grafana 2.1 brings improvements in three core areas: dashboarding, authentication, and data sources. +As with every Grafana release, there is a whole slew of new features, enhancements, and bug fixes. + +## More Dynamic Dashboards +The Templating system is one of the most powerful and well-used features of Grafana. +The 2.1 release brings numerous improvements that make dashboards more dynamic than ever before. + +### Multi-Value Template Variables +A template variable with Multi-Value enabled allows for the selection of multiple values at the same time. +These variables can then be used in any Panel to make them more dynamic, and to give you the perfect view of your data. +Multi-Value variables are also enabling the new `row repeat` and `panel repeat` feature described below. + +![Multi-Value Select](/img/docs/v2/multi-select.gif "Multi-Value Select") +

+ +### Repeating Rows and Panels +It’s now possible to create a dashboard that automatically adds (or removes) both rows and panels based +on selected variable values. Any row or any panel can be configured to repeat (duplicate itself) based +on a multi-value template variable.

+ +![Repeating Rows and Panels](/img/docs/v2/panel-row-repeat.gif "Repeating Rows and Panels") +

+ +### Dashboard Links and Navigation +To support better navigation between dashboards, it's now possible to create custom and dynamic links from individual +panels to appropriate Dashboards. You also have the ability to create flexible top-level links on any +given dashboard thanks to the new dashboard navigation bar feature. + +![Dashboard Links](/img/docs/v2/dash_links.png "Dashboard Links") + +Dashboard links can be added under dashboard settings. Either defined as static URLs with a custom icon or as dynamic +dashboard links or dropdowns based on custom dashboard search query. These links appear in the same +row under the top menu where template variables appear. + +- - - + +### Better local Dashboard support +Grafana can now index Dashboards saved locally as JSON from a given directory. These file based dashboards +will appear in the regular dashboard search along regular DB dashboards. + +> **Note:** Saving local dashboards back the folder is not supported; this feature is meant for statically generated JSON dashboards. + +- - - + +## New Authentication Options +New authentication methods add numerous options to manage users, roles and organizations. + +### LDAP support +This highly requested feature now allows your Grafana users to login with their LDAP credentials. +You can also specify mappings between LDAP group memberships and Grafana Organization user roles. + +### Basic Auth Support +You can now authenticate against the Grafana API utilizing a simple username and password with basic HTTP authentication. + +> **Note:** This can be useful for provisioning and configuring management systems that need +> to utilize the API without having to create an API key. + + +### Auth Proxy Support +You can now authenticate utilizing a header (eg. X-Authenticated-User, or X-WEBAUTH-USER) + +> **Note:** this can be useful in situations with reverse proxies. + + +### New “Read-only Editor” User Role +There is a new User role available in this version of Grafana: “Read-only Editor”. This role behaves just +like the Viewer role does in Grafana 2.0. That is you can edit graphs and queries but not save dashboards. +The Viewer role has been modified in Grafana 2.1 so that users assigned this role can no longer edit panels. + +- - - + +## Data source Improvements + +### InfluxDB 0.9 Support +Grafana 2.1 now comes with full support for InfluxDB 0.9. There is a new query editor designed from scratch +for the new features InfluxDB 0.9 enables. + +![InfluxDB Editor](/img/docs/v2/influx_09_editor_anim.gif "InfluxDB Editor") + +
+ +### OpenTSDB Improvements +Grafana OpenTSDB data source now supports template variable values queries. This means you can create +template variables that fetches the values from OpenTSDB (for example metric names, tag names, or tag values). +The query editor is also enhanced to limiting tags by metric. + +> **Note:** OpenTSDB config option tsd.core.meta.enable_realtime_ts must enabled for OpenTSDB lookup API) + +### New Data Source: KairosDB +The Cassandra backed time series database KairosDB is now supported in Grafana out of the box. Thank you to +masaori335 for his hard work in getting it to this point. + +- - - + +## Panel Improvements + +Grafana 2.1 gives you even more flexibility customizing how individual panels render. +Overriding the colors of specific series using regular expressions, changing how series stack, +and allowing string values will help you better understand your data at a glance. + +### Graph Panel +Define series color using regex rule. This is useful when you have templated graphs with series names +that change depending selected template variables. Using a regex style override rule you could +for example make all series that contain the word **CPU** `red` and assigned to the second y axis. + +![Define series color using regex rule](/img/docs/v2/regex_color_override.png "Define series color using regex rule") + +New series style override, negative-y transform and stack groups. Negative y transform is +very useful if you want to plot a series on the negative y scale without affecting the legend values like min or max or +the values shown in the hover tooltip. + +![Negative-y Transform](/img/docs/v2/negative-y.png "Negative-y Transform") + +![Negative-y Transform](/img/docs/v2/negative-y-form.png "Negative-y Transform") + +### Singlestat Panel +Now support string values. Useful for time series database like InfluxDB that supports +string values. + +### Changelog +For a detailed list and link to github issues for everything included in the 2.1 release please +view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. diff --git a/docs/sources/whatsnew/whats-new-in-v2-5.md b/docs/sources/whatsnew/whats-new-in-v2-5.md new file mode 100644 index 0000000..70d1fc6 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-5.md @@ -0,0 +1,108 @@ ++++ +title = "What's new in Grafana v2.5" +description = "Feature and improvement highlights for Grafana v2.5" +keywords = ["grafana", "new", "documentation", "2.5", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2-5/"] +weight = -3 +[_build] +list = false ++++ + +# What's new in Grafana v2.5 + +## Release highlights +This is an exciting release, and we want to share some of the highlights. The release includes many +fixes and enhancements to all areas of Grafana, like new Data Sources, a new and improved timepicker, user invites, panel +resize handles and improved InfluxDB and OpenTSDB support. + +### New time range controls +New Time picker + +A new timepicker with room for more quick ranges as well as new types of relative ranges, like `Today`, +`The day so far` and `This day last week`. Also an improved time and calendar picker that now works +correctly in UTC mode. + +### Elasticsearch + +Elasticsearch example +
+ +This release brings a fully featured query editor for Elasticsearch. You will now be able to visualize +logs or any kind of data stored in Elasticsearch. The query editor allows you to build both simple +and complex queries for logs or metrics. + +- Compute metrics from your documents, supported Elasticsearch aggregations: + - Count, Avg, Min, Max, Sum + - Percentiles, Std Dev, etc. +- Group by multiple terms or filters + - Specify group by options like Top 5 based on Avg @value +- Auto completion for field names +- Query only relevant indices based on time pattern +- Alias patterns for short readable series names + +Try the new Elasticsearch query editor on the [play.grafana.org](https://play.grafana.org/dashboard/db/elasticsearch-metrics) site. + +### CloudWatch + +Cloudwatch editor + +Grafana 2.5 ships with a new CloudWatch data source that will allow you to query and visualize CloudWatch +metrics directly from Grafana. + +- Rich editor with auto completion for metric names, namespaces and dimensions +- Templating queries for generic dashboards +- Alias patterns for short readable series names + +### Prometheus + +Prometheus editor + +Grafana 2.5 ships with a new Prometheus data source that will allow you to query and visualize data +stored in Prometheus. + + +### Mix different data sources +Mix data sources in the same dashboard or in the same graph! + +In previous releases you have been able to mix different data sources on the same dashboard. In v2.5 you +will be able to mix then on the same graph! You can enable this by selecting the built in `-- Mixed --` data source. +When selected this will allow you to specify data source on a per query basis. This will, for example, allow you +to plot metrics from different Graphite servers on the same Graph or plot data from Elasticsearch alongside +data from Prometheus. Mixing different data sources on the same graph works for any data source, even custom ones. + +### Panel Resize handles + + +This release adds resize handles to the bottom right corners of panels making it easy to resize both width and height. + +### User invites + + +This version also brings some new features for user management. + +- Organization admins can now invite new users (via email or manually via invite link) +- Users can signup using invite link and get automatically added to invited organization +- User signup workflow can (if enabled) contain an email verification step. +- Check out [#2353](https://github.com/grafana/grafana/issues/2353) for more info. + +### Miscellaneous improvements + +- InfluxDB query editor now supports math and AS expressions +- InfluxDB query editor now supports custom group by interval +- Panel drilldown link is easier to reach +- LDAP improvements (can now search for group membership if your LDAP server does not support memberOf attribute) +- More units for graph and singlestat panel (Length, Volume, Temperature, Pressure, Currency) +- Admin page for all organizations (remove / edit) + +### Breaking changes +There have been some changes to the data source plugin API. If you are using a custom plugin check that there is an update for it before you upgrade. Also +the new time picker does not currently support custom quick ranges like the last one did. This will likely be added in a +future release. + +### Changelog +For a detailed list and link to github issues for everything included in the 2.5 release please +view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. + +- - - + +### Download Grafana 2.5 now diff --git a/docs/sources/whatsnew/whats-new-in-v2-6.md b/docs/sources/whatsnew/whats-new-in-v2-6.md new file mode 100644 index 0000000..b9b27e8 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-6.md @@ -0,0 +1,127 @@ ++++ +title = "What's new in Grafana v2.6" +description = "Feature and improvement highlights for Grafana v2.6" +keywords = ["grafana", "new", "documentation", "2.6", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2-6/"] +weight = -4 +[_build] +list = false ++++ + +# What's new in Grafana v2.6 + +## Release highlights +The release includes a new Table panel, a new InfluxDB query editor, support for Elasticsearch Pipeline Metrics and +support for multiple Cloudwatch credentials. + +## Table Panel + + +The new table panel is very flexible, supporting both multiple modes for time series as well as for +table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. + +### Time series to rows + +In the most simple mode you can turn time series to rows. This means you get a `Time`, `Metric` and a `Value` column. +Where `Metric` is the name of the time series. + + + +### Table Transform +Above you see the options tab for the **Table Panel**. The most important option is the `To Table Transform`. +This option controls how the result of the metric/data query is turned into a table. + +### Column Styles +The column styles allow you control how dates and numbers are formatted. + +### Time series to columns +This transform allows you to take multiple time series and group them by time. Which will result in a `Time` column +and a column for each time series. + + + +In the screenshot above you can see how the same time series query as in the previous example can be transformed into +a different table by changing the `To Table Transform` to `Time series to columns`. + +### Time series to aggregations +This transform works very similar to the legend values in the Graph panel. Each series gets its own row. In the Options +tab you can select which aggregations you want using the plus button the Columns section. + + + +You have to think about how accurate the aggregations will be. It depends on what aggregation is used in the time series query, +how many data points are fetched, etc. The time series aggregations are calculated by Grafana after aggregation is performed +by the time series database. + +### Raw logs queries + +If you want to show documents from Elasticsearch pick `Raw Document` as the first metric. + + + +This in combination with the `JSON Data` table transform will allow you to pick which fields in the document +you want to show in the table. + + + +### Elasticsearch aggregations + +You can also make Elasticsearch aggregation queries without a `Date Histogram`. This allows you to +use Elasticsearch metric aggregations to get accurate aggregations for the selected time range. + + + +### Annotations + +The table can also show any annotations you have enabled in the dashboard. + + + +## The New InfluxDB Editor +The new InfluxDB editor is a lot more flexible and powerful. It supports nested functions, like `derivative`. +It also uses the same technique as the Graphite query editor in that it presents nested functions as chain of function +transformations. It tries to simplify and unify the complicated nature of InfluxDB's query language. + + + +In the `SELECT` row you can specify what fields and functions you want to use. If you have a +group by time you need an aggregation function. Some functions like derivative require an aggregation function. + +The editor tries simplify and unify this part of the query. For example: + +![](/img/docs/influxdb/select_editor.png) + +The above will generate the following InfluxDB `SELECT` clause: + +```sql +SELECT derivative(mean("value"), 10s) /10 AS "REQ/s" FROM .... +``` + +### Select multiple fields +Use the plus button and select Field > field to add another SELECT clause. You can also +specify an asterix `*` to select all fields. + +### Group By +To group by a tag click the plus icon at the end of the GROUP BY row. Pick a tag from the dropdown that appears. +You can remove the group by by clicking on the `tag` and then click on the x icon. + +The new editor also allows you to remove group by time and select `raw` table data. Which is very useful +in combination with the new Table panel to show raw log data stored in InfluxDB. + + + +## Pipeline metrics + +If you have Elasticsearch 2.x and Grafana 2.6 or above then you can use pipeline metric aggregations like +**Moving Average** and **Derivative**. Elasticsearch pipeline metrics require another metric to be based on. Use the eye icon next to the metric +to hide metrics from appearing in the graph. + +![](/img/docs/elasticsearch/pipeline_metrics_editor.png) + +## Changelog +For a detailed list and link to github issues for everything included in the 2.6 release please +view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. + +- - - + +Download Grafana 2.6 now diff --git a/docs/sources/whatsnew/whats-new-in-v3-0.md b/docs/sources/whatsnew/whats-new-in-v3-0.md new file mode 100644 index 0000000..cf6a518 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v3-0.md @@ -0,0 +1,225 @@ ++++ +title = "What's new in Grafana v3.0" +description = "Feature and improvement highlights for Grafana v3.0" +keywords = ["grafana", "new", "documentation", "3.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v3/"] +weight = -5 +[_build] +list = false ++++ + +# What's new in Grafana v3.0 + +## Commercial Support + +Commercial Support subscriptions for Grafana are now [generally available](https://grafana.com/support/plans/). + +Raintank is committed to a 100% open-source strategy for Grafana. We +do not want to go down the “open core” route. If your organization +finds Grafana valuable, please consider purchasing a subscription. Get +direct support, bug fixes, and training from the core Grafana team. + +## Plugins + +With the popularity of Grafana continuing to accelerate, it has been +challenging to keep up with all the requests for new features, new +panels, new data sources, and new functionality. Saying “no” so often +has been frustrating, especially for an open source project with such +a vibrant community. + +The team felt that it was time to dramatically improve extensibility +through plugin support. Grafana 3.0 comes with a completely revamped +plugin SDK / API. + +We’ve refactored our **Data Source** plugin architecture and added +two new plugin types: + +- **Panel** plugins let you add new panel types for your Dashboards. +- **App** plugins bundle **Panels** plugins, **Data Sources** plugins, +Dashboards, and Grafana **Pages**. Apps are a great way to provide an +entire experience right within Grafana. + +## Grafana.com + + + +[Grafana.com](https://grafana.com) offers a central repository where the community can come together to discover, create and +share plugins (data sources, panels, apps) and dashboards. + +We are also working on a hosted Graphite-compatible data source that will be optimized for use with Grafana. +It’ll be easy to combine your existing data source(s) with this OpenSaaS option. Finally, Grafana.com can +also be a hub to manage all your Grafana instances. You’ll be able to monitor their health and availability, +perform dashboard backups, and more. + +We are also working on a hosted Graphite-compatible Data Source that +will be optimized for use with Grafana. It’ll be easy to combine your +existing Data Source(s) with this OpenSaaS option. + +Finally, Grafana.com will also be a hub to manage all your Grafana +instances. You’ll be able to monitor their health and availability, +perform Dashboard backups, and more. + +Grafana.net will officially launch along with the stable version of +Grafana 3.0, but go to and check out the preview +and sign up for an account in the meantime. + +## grafana-cli + +Grafana 3.0 comes with a new command line tool called grafana-cli. You +can easily install plugins from Grafana.net with it. For +example: + +``` +grafana-cli install grafana-pie-chart-panel +``` + +## Personalization and Preferences + +The home dashboard, timezone and theme can now be customized on Organization +and user Profile level. Grafana can also track recently viewed dashboards, which +can then be displayed in the dashboard list panel. + +## Improved Playlists + +You can now save Playlists, and start them by using a Playlist URL. If +you update a running Playlist, it will update after its next cycle. + +This is powerful as it allows you to remote control Grafana. If you +have a big TV display showing Grafana in your company lobby, create a +playlist named Lobby, and start it on the computer connected to the +Lobby TV. + +You can now change the Lobby playlist and have the dashboards shown in +the Lobby update accordingly, automatically. + +The playlist does not even have to contain multiple Dashboards; you +can use this feature to reload the whole Dashboard (and Grafana) +periodically and remotely. + +You can also make Playlists dynamic by using Dashboard **tags** to +define the Playlist. + + + +## Improved UI + +We’ve always tried to focus on a good looking, usable, and responsive +UI. We’ve continued to pay a lot of attention to these areas in this +release. + +Grafana 3.0 has a dramatically updated UI that not only looks better +but also has a number of usability improvements. The side menu now +works as a dropdown that you can pin to the side. The Organization / +Profile / Sign out side menu links have been combined into an on hover +slide out menu. + +In addition, all the forms and the layouts of all pages have been +updated to look and flow better, and be much more consistent. There +are literally hundreds of UI improvements and refinements. + +Here’s the new side menu in action: + + + +And here's the new look for Dashboard settings: + + + +Check out the Play +Site to get a feel for some of the UI changes. + +## Improved Annotations + +It is now possible to define a link in each annotation. You can hover +over the link and click the annotation text. This feature is very +useful for linking to particular commits or tickets where more +detailed information can be presented to the user. + + + +## Data source variables + +This has been a top requested feature for very long we are excited to finally provide +this feature. You can now add a new `Data source` type variable. That will +automatically be filled with instance names of your data sources. + + + +You can then use this variable as the panel data source: + + + +This will allow you to quickly change data source server and reuse the +same dashboard for different instances of your metrics backend. For example +you might have Graphite running in multiple data centers or environments. + +## Prometheus, InfluxDB, and OpenTSDB improvements + +All three of these popular included Data Sources have seen a variety +of improvements in this release. Here are some highlights: + +### Prometheus + +The Prometheus Data Source now supports annotations. + +### InfluxDB + +You can now select the InfluxDB policy from the query editor. + + +Grafana 3.0 also comes with support for InfluxDB 0.11 and InfluxDB 0.12. + +### OpenTSDB + +OpenTSDB 2.2 is better supported and now supports millisecond precision. + +## Breaking changes + +Dashboards from v2.6 are compatible; no manual updates should be necessary. There could +be some edge case scenarios where dashboards using templating could stop working. +If that is the case just enter the edit view for the template variable and hit Update button. +This is due to a simplification of the variable format system where template variables are +now stored without any formatting (glob/regex/etc), this is done on the fly when the +variable is interpolated. + +- Plugin API: The plugin API has changed so if you are using a custom +data source (or panel) they need to be updated as well. + +- InfluxDB 0.8: This data source is no longer included in releases, +you can still install manually from [Grafana.com](https://grafana.com) + +- KairosDB: This data source has also no longer shipped with Grafana, +you can install it manually from [Grafana.com](https://grafana.com) + +## Plugin showcase + +Discovering and installing plugins is very quick and easy with Grafana 3.0 and [Grafana.com](https://grafana.com). Here +are a couple that I encourage you try! + +#### [Clock Panel](https://grafana.com/plugins/grafana-clock-panel) +Support's both current time and count down mode. + + +#### [Pie Chart Panel](https://grafana.com/plugins/grafana-piechart-panel) +A simple pie chart panel is now available as an external plugin. + + +#### [WorldPing App](https://grafana.com/plugins/raintank-worldping-app) +This is full blown Grafana App that adds new panels, data sources and pages to give +feature rich global performance monitoring directly from your on-prem Grafana. + + + +#### [Zabbix App](https://grafana.com/plugins/alexanderzobnin-zabbix-app) +This app contains the already very pouplar Zabbix data source plugin, 2 dashboards and a triggers panel. It is +created and maintained by [Alexander Zobnin](https://github.com/alexanderzobnin/grafana-zabbix). + + + +Check out the full list of plugins on [Grafana.com](https://grafana.com/plugins) + +## CHANGELOG + +For a detailed list and link to github issues for everything included +in the 3.0 release please view the +[CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. diff --git a/docs/sources/whatsnew/whats-new-in-v3-1.md b/docs/sources/whatsnew/whats-new-in-v3-1.md new file mode 100644 index 0000000..32fe80d --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v3-1.md @@ -0,0 +1,60 @@ ++++ +title = "What's new in Grafana v3.1" +description = "Feature and improvement highlights for Grafana v3.1" +keywords = ["grafana", "new", "documentation", "3.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v3-1/"] +weight = -6 +[_build] +list = false ++++ + +# What's new in Grafana v3.1 + +## Dashboard Export and Import + +The export feature is now accessed from the share menu. + + + +Dashboards exported from Grafana 3.1 are now more portable and easier for others to import than before. The export process extracts information data source types used by panels and adds these to a new `inputs` section in the dashboard json. So when you or another person tries to import the dashboard they will be asked to select data source and optional metric prefix options. + + + +The above screenshot shows the new import modal that gives you 3 options for how to import a dashboard. One notable new addition here is the ability to import directly from Dashboards shared on [Grafana.com](https://grafana.com). + +The next step in the import process: + + + +Here you can change the name of the dashboard and also pick what data sources you want the dashboard to use. The above screenshot shows a CollectD dashboard for Graphite that requires a metric prefix be specified. + +## Discover Dashboards + +On [Grafana.com](https://grafana.com) you can now browse and search for dashboards. We have already added a few but more are being uploaded every day. To import a dashboard just copy the dashboard URL and head back to Grafana, then Dashboard Search -> Import -> Paste Grafana.com Dashboard URL. + + + +## Constant template variables + +We added a new template variable named constant that makes it easier to share and export dashboard that have custom prefixes. + +## Dashboard URLs + +Having current time range and template variable value always sync with the URL makes it possible to always copy your current Grafana URL to share with a colleague without having to use the Share modal. + +## Internal metrics + +Do you want metrics about viewing metrics? Of course you do! In this release we added support for sending metrics about Grafana to graphite. You can configure interval and server in the config file. + +## Logging + +Switched logging framework to log15 to enable key value per logging and filtering based on different log levels. It's now possible to configure different log levels for different modules. + +### Breaking changes +- **Logging** format have been changed to improve log filtering. +- **Graphite PNG** Graphite PNG support dropped from Graph panel (use Grafana native PNG instead). +- **Migration** No longer possible to migrate dashboards from 1.x (Stored in ES or Influx 0.8). + +## CHANGELOG + +For a detailed list and link to github issues for everything included in the 3.1 release please view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. diff --git a/docs/sources/whatsnew/whats-new-in-v4-0.md b/docs/sources/whatsnew/whats-new-in-v4-0.md new file mode 100644 index 0000000..e0c23e9 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-0.md @@ -0,0 +1,176 @@ ++++ +title = "What's new in Grafana v4.0" +description = "Feature and improvement highlights for Grafana v4.0" +keywords = ["grafana", "new", "documentation", "4.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4/"] +weight = -7 +[_build] +list = false ++++ + +# What's new in Grafana v4.0 + +As usual this release contains a ton of minor new features, fixes and improved UX. But on top of the usual new goodies +is a core new feature: Alerting! Read on below for a detailed description of what's new in v4.0. + +## Alerting + +{{< imgbox max-width="40%" img="/img/docs/v4/drag_handles_gif.gif" caption="Alerting overview" >}} + +Alerting is a really revolutionary feature for Grafana. It transforms Grafana from a +visualization tool into a truly mission critical monitoring tool. The alert rules are very easy to +configure using your existing graph panels and threshold levels can be set simply by dragging handles to +the right side of the graph. The rules will continually be evaluated by grafana-server and +notifications will be sent out when the rule conditions are met. + +This feature has been worked on for over a year with many iterations and rewrites +just to make sure the foundations are really solid. We are really proud to finally release it! +Since the alerting execution is processed in the backend not all data source plugins are supported. +Right now Graphite, Prometheus, InfluxDB and OpenTSDB are supported. Elasticsearch is being worked +on but will be not ready for v4 release. + +
+ +### Rules + +{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} + +The rule configuration allows you to specify a name, how often the rule should be evaluated and a series +of conditions that all need to be true for the alert to fire. + +Currently the only condition type that exists is a `Query` condition that allows you to +specify a query letter, time range and an aggregation function. The letter refers to +a query you already have added in the **Metrics** tab. The result from the +query and the aggregation function is a single value that is then used in the threshold check. + +We plan to add other condition types in the future, like `Other Alert`, where you can include the state +of another alert in your conditions, and `Time Of Day`. + +### Notifications + +{{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}} + +Alerting would not be very useful if there was no way to send notifications when rules trigger and change state. You +can set up notifications of different types. We currently have `Slack`, `PagerDuty`, `Email` and `Webhook` with more in the +pipe that will be added during beta period. The notifications can then be added to your alert rules. +If you have configured an external image store in the grafana.ini config file (s3, webdav, and azure_blob options available) +you can get very rich notifications with an image of the graph and the metric +values all included in the notification. + +### Annotations + +Alert state changes are recorded in a new annotation store that is built into Grafana. This store +currently only supports storing annotations in Grafana's own internal database (mysql, postgres or sqlite). +The Grafana annotation storage is currently only used for alert state changes but we hope to add the ability for users +to add graph comments in the form of annotations directly from within Grafana in a future release. + +### Alert List Panel + +{{< imgbox max-width="30%" img="/img/docs/v4/alert_list_panel.png" caption="Alert List Panel" >}} + +This new panel allows you to show alert rules or a history of alert rule state changes. You can filter based on states you are +interested in. This panel is very useful for overview style dashboards. + +
+ +## Ad-hoc filter variable + +{{< imgbox max-width="30%" img="/img/docs/v4/adhoc_filters.gif" caption="Ad-hoc filters variable" >}} + +This is a new and very different type of template variable. It will allow you to create new key/value filters on the fly +with autocomplete for both key and values. The filter condition will be automatically applied to all +queries that use that data source. This feature opens up more exploratory dashboards. In the gif animation to the right +you have a dashboard for Elasticsearch log data. It uses one query variable that allow you to quickly change how the data +is grouped, and an interval variable for controlling the granularity of the time buckets. What was missing +was a way to dynamically apply filters to the log query. With the `Ad-Hoc Filters` variable you can +dynamically add filters to any log property! + +## UX Improvements + +We always try to bring some UX/UI refinements and polish in every release. + +### TV-mode and Kiosk mode + +
+
+

+ Grafana is so often used on wall mounted TVs that we figured a clean TV mode would be + really nice. In TV mode the top navbar, row and panel controls will all fade to transparent. +

+ +

+ This happens automatically after one minute of user inactivity but can also be toggled manually + with the d v sequence shortcut. Any mouse movement or keyboard action will + restore navbar and controls. +

+ +

+ Another feature is the kiosk mode. This can be enabled with d k + shortcut or by adding &kiosk to the URL when you load a dashboard. + In kiosk mode the navbar is completely hidden/removed from view. +

+
+
+ {{< lightboxhelper max-width="100%" img="/img/docs/v4/tvmode.png" caption="TV mode" >}} + +
+
+ +### New row menu and add panel experience + +{{< imgbox max-width="50%" img="/img/docs/v4/add_panel.gif" caption="Add Panel flow" >}} + +We spent a lot of time improving the dashboard building experience to make it both +more efficient and easier for beginners. After many good but not great experiments +with a `build mode` we eventually decided to just improve the green row menu and +continue work on a `build mode` for a future release. + +The new row menu automatically slides out when you mouse over the edge of the row. You no longer need +to hover over the small green icon and then click it to expand the row menu. + +There are some minor improvements to drag and drop behavior. Now when dragging a panel from one row +to another you will insert the panel and Grafana will automatically make room for it. +When you drag a panel within a row you will simply reorder the panels. + +If you look at the animation to the right you can see that you can drag and drop a new panel. This is not +required, you can also just click the panel type and it will be inserted at the end of the row +automatically. Dragging a new panel has an advantage in that you can insert a new panel where ever you want +not just at the end of the row. + +We plan to further improve dashboard building in the future with a more rich grid and layout system. + +### Keyboard shortcuts + +{{< imgbox max-width="40%" img="/img/docs/v4/shortcuts.png" caption="Shortcuts" >}} + +Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel +by hovering over it with your mouse. With a panel focused you can simply hit `e` to toggle panel +edit mode, or `v` to toggle fullscreen mode. `p r` removes the panel. `p s` opens share +modal. + +Some nice navigation shortcuts are: + +- `g h` for go to home dashboard +- `s s` open search with starred pre-selected +- `s t` open search in tags list view + +
+ +## Upgrade and Breaking changes + +There are no breaking changes. Old dashboards and features should work the same. Grafana-server will automatically upgrade its db +schema on restart. It's advisable to do a backup of Grafana's database before updating. + +If you are using plugins make sure to update your plugins as some might not work perfectly v4. + +You can update plugins using grafana-cli + + grafana-cli plugins update-all + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v4-1.md b/docs/sources/whatsnew/whats-new-in-v4-1.md new file mode 100644 index 0000000..ab9f5cd --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-1.md @@ -0,0 +1,66 @@ ++++ +title = "What's new in Grafana v4.1" +description = "Feature and improvement highlights for Grafana v4.1" +keywords = ["grafana", "new", "documentation", "4.1.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-1/"] +weight = -8 +[_build] +list = false ++++ + +## What's new in Grafana v4.1 +- **Graph**: Support for shared tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274) +- **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin) +- **Opsgenie**: Add OpsGenie notification integratiion [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc) +- **Cloudwatch**: Make it possible to specify access and secret key on the data source configuration page [#6697](https://github.com/grafana/grafana/issues/6697) +- **Elasticsearch**: Added support for Elasticsearch 5.x [#5740](https://github.com/grafana/grafana/issues/5740), thx [@lpic10](https://github.com/lpic10) +- **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) +- [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +### Shared tooltip + +{{< imgbox max-width="60%" img="/img/docs/v41/shared_tooltip.gif" caption="Shared tooltip" >}} + +Showing the tooltip on all panels at the same time has been a long standing request in Grafana and we are really happy to finally be able to release it. +You can enable/disable the shared tooltip from the dashboard settings menu or cycle between default, shared tooltip and shared crosshair by pressing Ctrl/Cmd+O. + +
+ +### Help text for panel + +{{< imgbox max-width="60%" img="/img/docs/v41/helptext_for_panel_settings.png" caption="Hovering help text" >}} + +You can set a help text in the general tab on any panel. The help text is using Markdown to enable better formatting and linking to other sites that can provide more information. + +
+ +{{< imgbox max-width="60%" img="/img/docs/v41/helptext_hover.png" caption="Hovering help text" >}} + +Panels with a help text available have a little indicator in the top left corner. You can show the help text by hovering the icon. +
+ +### Easier Cloudwatch configuration + +{{< imgbox max-width="60%" img="/img/docs/v41/cloudwatch_settings.png" caption="Cloudwatch configuration" >}} + +In Grafana 4.1.0 you can configure your Cloudwatch data source with `access key` and `secret key` directly in the data source configuration page. +This enables people to use the Cloudwatch data source without having access to the filesystem where Grafana is running. + +Once the `access key` and `secret key` have been saved the user will no longer be able to view them. +
+ +## Upgrade and Breaking changes + +Elasticsearch 1.x is no longer supported. Please upgrade to Elasticsearch 2.x or 5.x. Otherwise Grafana 4.1.0 contains no breaking changes. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. + +## Download + +Head to [v4.1 download page](/download/4_1_0/) for download links and instructions. + +## Thanks +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports and feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-2.md b/docs/sources/whatsnew/whats-new-in-v4-2.md new file mode 100644 index 0000000..46d33af --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-2.md @@ -0,0 +1,86 @@ ++++ +title = "What's new in Grafana v4.2" +description = "Feature and improvement highlights for Grafana v4.2" +keywords = ["grafana", "new", "documentation", "4.2.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-2/"] +weight = -9 +[_build] +list = false ++++ + +## What's new in Grafana v4.2 + +Grafana v4.2 Beta is now [available for download](https://grafana.com/grafana/download/4.2.0). +Just like the last release this one contains lots bug fixes and minor improvements. +We are very happy to say that 27 of 40 issues was closed by pull requests from the community. +Big thumbs up! + +## Release Highlights + +- **Hipchat**: Adds support for sending alert notifications to hipchat [#6451](https://github.com/grafana/grafana/issues/6451), thx [@jregovic](https://github.com/jregovic) +- **Telegram**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) +- **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [@huydx](https://github.com/huydx) +- **Templating**: Make $__interval and $__interval_ms global built in variables that can be used in by any data source (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) +- **Alerting**: Adds deduping of alert notifications [#7632](https://github.com/grafana/grafana/pull/7632) +- **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) +- **Orgs**: Sharing dashboards using Grafana share feature will now redirect to correct org. [#6948](https://github.com/grafana/grafana/issues/6948) +- [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +### New alert notification channels + +This release adds **five** new alert notifications channels, all of them contributed by the community. + +- Hipchat +- Telegram +- LINE +- Pushover +- Threema + +### Templating + +We added two new global built in variables in grafana. `$__interval` and `$__interval_ms` are now reserved template names in grafana and can be used by any data source. +We might add more global built in variables in the future and if we do we will prefix them with `$__`. So please avoid using that in your template variables. + +### Dedupe alert notifications when running multiple servers + +In this release we will dedupe alert notifications when you are running multiple servers. +This makes it possible to run alerting on multiple servers and only get one notification. + +We currently solve this with sql transactions which puts some limitations for how many servers you can use to execute the same rules. +3-5 servers should not be a problem but as always, it depends on how many alerts you have and how frequently they execute. + +Next up for a better HA situation is to add support for workload balancing between Grafana servers. + +### Alerting more info + +You can now see the reason why an alert triggered in the alert history. Its also easier to detect when an alert is set to `alerting` due to the `no_data` option. + +### Improved support for multi-org setup + +When loading dashboards we now set an query parameter called orgId. So we can detect from which org an user shared a dashboard. +This makes it possible for users to share dashboards between orgs without changing org first. + +We aim to introduce [dashboard groups](https://github.com/grafana/grafana/issues/1611) sometime in the future which will introduce access control and user groups within one org. +Making it possible to have users in multiple groups and have detailed access control. + +## Upgrade and Breaking changes + +If you're using HTTPS in grafana we now force you to use TLS 1.2 and the most secure ciphers. +We think its better to be secure by default rather then making it configurable. +If you want to run HTTPS with lower versions of TLS we suggest you put a reserve proxy in front of grafana. + +If you have template variables name `$__interval` or `$__interval_ms` they will no longer work since these keywords +are reserved as global built in variables. We might add more global built in variables in the future and if we do, we will prefix them with `$__`. So please avoid using that in your template variables. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. + +## Download + +Head to [v4.2-beta download page](/download/4_2_0/) for download links and instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports and feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-3.md b/docs/sources/whatsnew/whats-new-in-v4-3.md new file mode 100644 index 0000000..deb62c6 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-3.md @@ -0,0 +1,102 @@ ++++ +title = "What's new in Grafana v4.3" +description = "Feature and improvement highlights for Grafana v4.3" +keywords = ["grafana", "new", "documentation", "4.3.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-3/"] +weight = -10 +[_build] +list = false ++++ + +## What's new in Grafana v4.3 + +Grafana v4.3 Beta is now [available for download](https://grafana.com/grafana/download/4.3.0-beta1). + +## Release Highlights + +- New [Heatmap Panel](http://docs.grafana.org/features/panels/heatmap/) +- Graph Panel Histogram Mode +- Elasticsearch Histogram Aggregation +- Prometheus Table data format +- New [MySQL Data Source](http://docs.grafana.org/features/datasources/mysql/) (alpha version to get some early feedback) +- 60+ small fixes and improvements, most of them contributed by our fantastic community! + +Check out the [New Features in v4.3 Dashboard](https://play.grafana.org/dashboard/db/new-features-in-v4-3?orgId=1) on the Grafana Play site for a showcase of these new features. + +## Histogram Support + +A Histogram is a kind of bar chart that groups numbers into ranges, often called buckets or bins. Taller bars show that more data falls in that range. + +The Graph Panel now supports Histograms. + +![](/img/docs/v43/heatmap_histogram.png) + +## Histogram Aggregation Support for Elasticsearch + +Elasticsearch is the only supported data source that can return pre-bucketed data (data that is already grouped into ranges). With other data sources there is a risk of returning inaccurate data in a histogram due to using already aggregated data rather than raw data. This release adds support for Elasticsearch pre-bucketed data that can be visualized with the new [Heatmap Panel](http://docs.grafana.org/features/panels/heatmap/). + +## Heatmap Panel + +The Histogram support in the Graph Panel does not show changes over time - it aggregates all the data together for the chosen time range. To visualize a histogram over time, we have built a new [Heatmap Panel](http://docs.grafana.org/features/panels/heatmap/). + +Every column in a Heatmap is a histogram snapshot. Instead of visualizing higher values with higher bars, a heatmap visualizes higher values with color. The histogram shown above is equivalent to one column in the heatmap shown below. + +![](/img/docs/v43/heatmap_histogram_over_time.png) + +The Heatmap panel also works with Elasticsearch Histogram Aggregations for more accurate server side bucketing. + +![](/assets/img/blog/v4/elastic_heatmap.jpg) + +## MySQL Data Source (alpha) + +This release includes a [new core data source for MySQL](http://docs.grafana.org/features/datasources/mysql/). You can write any possible MySQL query and format it as either Time Series or Table Data allowing it be used with the Graph Panel, Table Panel and SingleStat Panel. + +We are still working on the MySQL data source. As it's missing some important features, like templating and macros and future changes could be breaking, we are +labeling the state of the data source as Alpha. Instead of holding up the release of v4.3 we are including it in its current shape to get some early feedback. So please try it out and let us know what you think on [twitter](https://twitter.com/intent/tweet?text=.%40grafana&source=4_3_beta_blog&related=blog) or on our [community forum](https://community.grafana.com/c/releases). Is this a feature that you would use? How can we make it better? + +**The query editor can show the generated and interpolated SQL that is sent to the MySQL server.** + +![](/img/docs/v43/mysql_table_query.png) + +**The query editor will also show any errors that resulted from running the query (very useful when you have a syntax error!).** + +![](/img/docs/v43/mysql_query_error.png) + +## Health Check Endpoint + +Now you can monitor the monitoring with the Health Check Endpoint! The new `/api/health` endpoint returns HTTP 200 OK if everything is up and HTTP 503 Error if the Grafana database cannot be pinged. + +## Lazy Load Panels + +Grafana now delays loading panels until they become visible (scrolled into view). This means panels out of view are not sending requests thereby reducing the load on your time series database. + +## Prometheus - Table Data (column per label) + +The Prometheus data source now supports the Table Data format by automatically assigning a column to a label. This makes it really easy to browse data in the table panel. + +![](/img/docs/v43/prom_table_cols_as_labels.png) + +## Other Highlights From The Changelog + +Changes: + +- **Table**: Support to change column header text [#3551](https://github.com/grafana/grafana/issues/3551) +- **InfluxDB**: influxdb query builder support for ORDER BY and LIMIT (allows TOPN queries) [#6065](https://github.com/grafana/grafana/issues/6065) Support influxdb's SLIMIT Feature [#7232](https://github.com/grafana/grafana/issues/7232) thx [@thuck](https://github.com/thuck) +- **Graph**: Support auto grid min/max when using log scale [#3090](https://github.com/grafana/grafana/issues/3090), thx [@bigbenhur](https://github.com/bigbenhur) +- **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778) +- **Server**: Support listening on a Unix socket [#4030](https://github.com/grafana/grafana/issues/4030), thx [@mitjaziv](https://github.com/mitjaziv) + +Fixes: + +- **MySQL**: 4-byte UTF8 not supported when using MySQL database (allows Emojis in Dashboard Names) [#7958](https://github.com/grafana/grafana/issues/7958) +- **Dashboard**: Description tooltip is not fully displayed [#7970](https://github.com/grafana/grafana/issues/7970) + +Lots more enhancements and fixes can be found in the [Changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Download + +Head to the [v4.3 download page](https://grafana.com/grafana/download) for download links and instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports, helping out on our [community site](https://community.grafana.com/) and providing feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-4.md b/docs/sources/whatsnew/whats-new-in-v4-4.md new file mode 100644 index 0000000..6d55ba1 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-4.md @@ -0,0 +1,47 @@ ++++ +title = "What's new in Grafana v4.4" +description = "Feature and improvement highlights for Grafana v4.4" +keywords = ["grafana", "new", "documentation", "4.4.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-4/"] +weight = -11 +[_build] +list = false ++++ + +## What's new in Grafana v4.4 + +Grafana v4.4 is now [available for download](https://grafana.com/grafana/download/4.4.0). + +**Highlights**: + +- Dashboard History - version control for dashboards. + +## New Features + +**Dashboard History**: View dashboard version history, compare any two versions (summary and json diffs), restore to old version. This big feature +was contributed by **Walmart Labs**. Big thanks to them for this massive contribution! +Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638) +Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472) + +## Enhancements +- **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk) +- **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller) +- **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo) +- **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617) +- **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang) + +## Minor Enhancements + +- **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia) + +## Bug Fixes + +- **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628) + +## Download + +Head to the [v4.4 download page](https://grafana.com/grafana/download) for download links and instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports, helping out on our [community site](https://community.grafana.com/) and providing feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-5.md b/docs/sources/whatsnew/whats-new-in-v4-5.md new file mode 100644 index 0000000..50e9384 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-5.md @@ -0,0 +1,68 @@ ++++ +title = "What's new in Grafana v4.5" +description = "Feature and improvement highlights for Grafana v4.5" +keywords = ["grafana", "new", "documentation", "4.5", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-5/"] +weight = -12 +[_build] +list = false ++++ + +# What's new in Grafana v4.5 + +## Highlights + +### New prometheus query editor + +The new query editor has full syntax highlighting. As well as auto complete for metrics, functions, and range vectors. There are also integrated function docs right from the query editor! + +{{< docs-imagebox img="/img/docs/v45/prometheus_query_editor_still.png" class="docs-image--block" animated-gif="/img/docs/v45/prometheus_query_editor.gif" >}} + +### Elasticsearch: Add ad-hoc filters from the table panel + +{{< docs-imagebox img="/img/docs/v45/elastic_ad_hoc_filters.png" class="docs-image--block" >}} + +### Table cell links! +Create column styles that turn cells into links that use the value in the cell (or other row values) to generate a URL to another dashboard or system: +![](/img/docs/v45/table_links.jpg) + +### Query Inspector +Query Inspector is a new feature that shows query requests and responses. This can be helpful if a graph is not shown or shows something very different than what you expected. +For more information about query inspector, refer to [using grafanas query inspector to troubleshoot issues](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630). +![](/img/docs/v45/query_inspector.png) + +## Changelog + +### New Features + +- **Table panel**: Render cell values as links that can have an URL template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +- **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052). +- **MySQL**: New rich query editor with syntax highlighting +- **Prometheus**: New rich query editor with syntax highlighting, metric and range auto complete and integrated function docs. [#5117](https://github.com/grafana/grafana/issues/5117) + +### Enhancements + +- **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) +- **Graphite**: Calls to Graphite API /metrics/find now include panel or dashboard time range (from and until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) +- **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) +- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). + +### Breaking change + +- **InfluxDB/Elasticsearch**: The panel and data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. + +This option is now renamed (and moved to Options sub section above your queries): +![image|519x120](upload://ySjHOVpavV6yk9LHQxL9nq2HIsT.png) + +Data source selection and options and help are now above your metric queries. +![image|690x179](upload://5kNDxKgMz1BycOKgG3iWYLsEVXv.png) + +### Minor Changes + +- **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) +- **InfluxDB**: Added parenthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) + +## Bug Fixes + +- **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800) +- **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510) diff --git a/docs/sources/whatsnew/whats-new-in-v4-6.md b/docs/sources/whatsnew/whats-new-in-v4-6.md new file mode 100644 index 0000000..4e04c61 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-6.md @@ -0,0 +1,72 @@ ++++ +title = "What's new in Grafana v4.6" +description = "Feature and improvement highlights for Grafana v4.6" +keywords = ["grafana", "new", "documentation", "4.6", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-6/"] +weight = -13 +[_build] +list = false ++++ + +# What's new in Grafana v4.6 + +Grafana v4.6 brings many enhancements to Annotations, Cloudwatch and Prometheus. It also adds support for Postgres as metric and table data source! + +### Annotations + +{{< docs-imagebox img="/img/docs/v46/add_annotation_region.png" max-width= "800px" >}} + +You can now add annotation events and regions right from the graph panel! Just hold Ctrl/Cmd+Click or drag region to open the **Add Annotation** view. The +[Annotations]({{< relref "../dashboards/annotations.md" >}}) documentation is updated to include details on this new exciting feature. + +### Cloudwatch + +Cloudwatch now supports alerting. Set up alert rules for any Cloudwatch metric! + +{{< docs-imagebox img="/img/docs/v46/cloudwatch_alerting.png" max-width= "800px" >}} + +### Postgres + +Grafana v4.6 now ships with a built-in data source plugin for Postgres. Have logs or metric data in Postgres? You can now visualize that data and +define alert rules on it like any of our other data sources. + +{{< docs-imagebox img="/img/docs/v46/postgres_table_query.png" max-width= "800px" >}} + +### Prometheus + +New enhancements include support for **instant queries** and improvements to query editor in the form of autocomplete for label names and label values. +This makes exploring and filtering Prometheus data much easier. + +## Changelog + +### New Features + +- **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) +- **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) +- **Graph**: Add support for local formatting in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +- **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) +- **Unit types**: New date and time unit types added, useful in singlestat to show dates and times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +- **CLI**: Make it possible to install plugins from any URL [#5873](https://github.com/grafana/grafana/issues/5873) +- **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add support for alerting using the cloudwatch data source [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +- **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +- **Timezone**: Time ranges like Today and Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +- **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +- **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +- **Data sources**: closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) + +### Minor Changes + +- **SMTP**: Make it possible to set specific EHLO for SMTP client. [#9319](https://github.com/grafana/grafana/issues/9319) +- **Dataproxy**: Allow Grafana to renegotiate TLS connection [#9250](https://github.com/grafana/grafana/issues/9250) +- **HTTP**: set net.Dialer.DualStack to true for all HTTP clients [#9367](https://github.com/grafana/grafana/pull/9367) +- **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +- **Slack**: Allow images to be uploaded to slack when Token is present [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +- **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +- **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +- **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) +- **Postgres**: modify group by time macro so it can be used in select clause [#9527](https://github.com/grafana/grafana/pull/9527), thanks [@svenklemm](https://github.com/svenklemm) + +### Tech +- **Go**: Grafana is now built using golang 1.9 diff --git a/docs/sources/whatsnew/whats-new-in-v5-0.md b/docs/sources/whatsnew/whats-new-in-v5-0.md new file mode 100644 index 0000000..8765585 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-0.md @@ -0,0 +1,148 @@ ++++ +title = "What's new in Grafana v5.0" +description = "Feature and improvement highlights for Grafana v5.0" +keywords = ["grafana", "new", "documentation", "5.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5/"] +weight = -14 +[_build] +list = false ++++ + +# What's new in Grafana v5.0 + +This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements. + +- [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts. +- [New UX]({{< relref "#new-ux-layout-engine" >}}). The UI has big improvements in both look and function. +- [New Light Theme]({{< relref "#new-light-theme" >}}) is now looking really nice. +- [Dashboard Folders]({{< relref "#dashboard-folders" >}}) helps you keep your dashboards organized. +- [Permissions]({{< relref "#dashboard-folders" >}}) on folders and dashboards helps manage larger Grafana installations. +- [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system. +- [Data source provisioning]({{< relref "#data-sources" >}}) makes it possible to set up data sources via config files. +- [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to set up dashboards via config files. +- [Persistent dashboard URL's]({{< relref "#dashboard-model-persistent-url-s-and-api-changes" >}}) makes it possible to rename dashboards without breaking links. +- [Graphite Tags and Integrated Function Docs]({{< relref "#graphite-tags-integrated-function-docs" >}}). + +### Video showing new features + + +
+ +## New Dashboard Layout Engine + +{{< docs-imagebox img="/img/docs/v50/new_grid.png" max-width="1000px" class="docs-image--right">}} + +The new dashboard layout engine allows for much easier movement and sizing of panels, as other panels now move out of the way in +a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens +up many new types of layouts where panels of different heights can be aligned easily. Check out the new grid in the video +above or on the [play site](https://play.grafana.org). All your existing dashboards will automatically migrate to the +new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible +with older versions of Grafana. + +
+ +## New UX + +{{< docs-imagebox img="/img/docs/v50/new_ux_nav.png" max-width="1000px" class="docs-image--right" >}} + +Almost every page has seen significant UX improvements. All pages (except dashboard pages) have a new tab-based layout that improves navigation between pages. The side menu has also changed quite a bit. You can still hide the side menu completely if you click on the Grafana logo. + +
+ +## Dashboard Settings + +{{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}} +Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard +settings views have been combined with a side nav which allows you to easily move between different setting categories. + +
+ +## New Light Theme + +{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}} + +This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result. + +
+ +## Dashboard Folders + +{{< docs-imagebox img="/img/docs/v50/new_search.png" max-width="1000px" class="docs-image--right" >}} + +The big new feature that comes with Grafana v5.0 is dashboard folders. Now you can organize your dashboards in folders, +which is very useful if you have a lot of dashboards or multiple teams. + +- New search design adds expandable sections for each folder, starred and recently viewed dashboards. +- New manage dashboard pages enable batch actions and views for folder settings and permissions. +- Set permissions on folders and have dashboards inherit the permissions. + +## Teams + +A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams. +We hope to do more with teams in future releases like integration with LDAP and a team landing page. + +## Permissions + +{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}} + +You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and +replaced with specific teams or users enabling more control over what a user can see and edit. + +Dashboard permissions only limits what dashboards and folders a user can view and edit not which +data sources a user can access nor what queries a user can issue. + +
+ +## Provisioning from configuration + +In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards. +But that required the service to be running before you started creating dashboards and you also needed to +set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active +provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can +be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs +and alerts as well. + +### Data sources + +Data sources can now be set up using config files. These data sources are by default not editable from the Grafana GUI. +It's also possible to update and delete data sources from the config file. More info in the [data source provisioning docs](/administration/provisioning/#datasources). + +### Dashboards + +We also deprecated the `[dashboard.json]` in favor of our new dashboard provisioner that keeps dashboards on disk +in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old +`[dashboard.json]` feature. Instead of storing the dashboard in memory we now insert the dashboard into the database, +which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that +expects the dashboards to exist in the database. More info in the [dashboard provisioning docs]({{< relref "../administration/provisioning.md" >}}) + + +## Graphite Tags and Integrated Function Docs + +{{< docs-imagebox img="/img/docs/v50/graphite_tags.png" max-width="1000px" class="docs-image--right" >}} + +The Graphite query editor has been updated to support the latest Graphite version (v1.2) that adds +many new functions and support for querying by tags. You can now also view function documentation right in the query editor! + +Read more on [Graphite Tag Support](http://graphite.readthedocs.io/en/latest/tags.html?highlight=tags). + +
+ +## Dashboard model, persistent URLs and API changes + +We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically +generated if not provided when creating a dashboard and will have a length of 9-12 characters. + +The unique identifier allows having persistent URLs for accessing dashboards, sharing them +between instances and when using [dashboard provisioning]((/administration/provisioning/#reusable-dashboard-urls)). This means that dashboard can +be renamed without breaking any links. We're changing the URL format for dashboards +from `/dashboard/db/:slug` to `/d/:uid/:slug`. We'll keep supporting the old slug-based URLs for dashboards +and redirects to the new one for backward compatibility. Please note that the old slug-based URLs +have been deprecated and will be removed in a future release. + +Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough). +This might seem like a small change, but we are incredibly excited about it since it will make it +much easier to manage, collaborate and navigate between dashboards. + +### API changes +New uid-based routes in the dashboard API have been introduced to retrieve and delete dashboards. +The corresponding slug-based routes have been deprecated and will be removed in a future release. diff --git a/docs/sources/whatsnew/whats-new-in-v5-1.md b/docs/sources/whatsnew/whats-new-in-v5-1.md new file mode 100644 index 0000000..e4bbbe6 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-1.md @@ -0,0 +1,123 @@ ++++ +title = "What's new in Grafana v5.1" +description = "Feature and improvement highlights for Grafana v5.1" +keywords = ["grafana", "new", "documentation", "5.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-1/"] +weight = -15 +[_build] +list = false ++++ + +# What's new in Grafana v5.1 + +Grafana v5.1 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Improved scrolling experience]({{< relref "#improved-scrolling-experience" >}}) +- [Improved docker image]({{< relref "#improved-docker-image-breaking-change" >}}) with a breaking change! +- [Heatmap support for Prometheus]({{< relref "#prometheus" >}}) +- [Microsoft SQL Server]({{< relref "#microsoft-sql-server" >}}) as metric and table data source! +- [Dashboards and Panels]({{< relref "#dashboards-panels" >}}) Improved adding panels to dashboards and enhancements to Graph and Table panels. +- [New variable interpolation syntax]({{< relref "#new-variable-interpolation-syntax" >}}) +- [Improved workflow for provisioned dashboards]({{< relref "#improved-workflow-for-provisioned-dashboards" >}}) + +## Improved scrolling experience + +In Grafana v5.0 we introduced a new scrollbar component. Unfortunately this introduced a lot of issues and in some scenarios removed +the native scrolling functionality. Grafana v5.1 ships with a native scrollbar for all pages together with a scrollbar component for +the dashboard grid and panels that's not overriding the native scrolling functionality. We hope that these changes and improvements should +make the Grafana user experience much better! + +## Improved Docker image (breaking change) + +Grafana v5.1 brings an improved official docker image which should make it easier to run and use the Grafana docker image and at the same time give more control to the user how to use/run it. + +We've switched the id of the grafana user running Grafana inside a docker container. Unfortunately this means that files created prior to 5.1 won't have the correct permissions for later versions and thereby this introduces a breaking change. +We made this change so that it would be easier for you to control what user Grafana is executed as (see examples below). + +Version | User | User ID +--------|---------|--------- +< 5.1 | grafana | 104 +>= 5.1 | grafana | 472 + +Please read the [updated documentation](/installation/docker/#migrate-to-v51-or-later) which includes migration instructions and more information. + +## Prometheus + +{{< docs-imagebox img="/img/docs/v51/prometheus_heatmap.png" max-width="800px" class="docs-image--right" >}} + +The Prometheus data source now support transforming Prometheus histograms to the heatmap panel. Prometheus histogram is a powerful feature, and we're +really happy to finally allow our users to render those as heatmaps. Please read [Heatmap panel documentation](/features/panels/heatmap/#pre-bucketed-data) +for more information on how to use it. + +Prometheus query editor also got support for autocomplete of template variables. More information in the [Prometheus data source documentation]({{< relref "../datasources/prometheus/" >}}). + +
+ +## Microsoft SQL Server + +{{< docs-imagebox img="/img/docs/v51/mssql_query_editor_showcase.png" max-width= "800px" class="docs-image--right" >}} + +Grafana v5.1 now ships with a built-in Microsoft SQL Server (MSSQL) data source plugin that allows you to query and visualize data from any +Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database. Do you have metric or log data in MSSQL? You can now visualize +that data and define alert rules on it like with any of Grafana's other core data sources. + +Please read [Using Microsoft SQL Server in Grafana documentation]({{< relref "../datasources/mssql/" >}}) for more detailed information on how to get started and use it. + +
+ +## Dashboards and Panels + +### Adding new panels to dashboards + +{{< docs-imagebox img="/img/docs/v51/dashboard_add_panel.png" max-width= "800px" class="docs-image--right" >}} + +The control for adding new panels to dashboards have got some enhancements and now includes functionality to search for the type of panel +you want to add. Further, the control has tabs separating functionality for adding new panels and pasting +copied panels. + +By copying a panel in a dashboard it will be displayed in the `Paste` tab in *any* dashboard and allows you to paste the +copied panel into the current dashboard. + +{{< docs-imagebox img="/img/docs/v51/dashboard_panel_copy.png" max-width= "300px" >}} + +
+ +### Graph Panel + +New enhancements include support for multiple series stacking in histogram mode, thresholds for right Y axis, aligning left and right Y-axes to one level and additional units. More information in the [Graph panel documentation]({{< relref "../panels/visualizations/graph-panel.md" >}}). + +### Table Panel + +New enhancements include support for mapping a numeric value/range to text and additional units. More information in the [Table panel documentation](/features/panels/table_panel/#string). + +## New variable interpolation syntax + +We now support a new option for rendering variables that gives the user full control of how the value(s) should be rendered. +In the table below you can see some examples and you can find all different options in the [Variables documentation](http://docs.grafana.org/variables/templates-and-variables/#advanced-formatting-options). + +Filter Option | Example | Raw | Interpolated | Description +------------ | ------------- | ------------- | ------------- | ------------- +`glob` | ${servers:glob} | `'test1', 'test2'` | `{test1,test2}` | Formats multi-value variable into a glob +`regex` | ${servers:regex} | `'test.', 'test2'` | (test\.|test2) | Formats multi-value variable into a regex string +`pipe` | ${servers:pipe} | `'test.', 'test2'` | test.|test2 | Formats multi-value variable into a pipe-separated string +`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string + +## Improved workflow for provisioned dashboards + +{{< docs-imagebox img="/img/docs/v51/provisioning_cannot_save_dashboard.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.1 brings an improved workflow for provisioned dashboards: + +- A populated `id` property in JSON is now automatically removed when provisioning dashboards. +- When making changes to a provisioned dashboard you can `Save` the dashboard which now will bring up a *Cannot save provisioned dashboard* dialog like seen in the screenshot to the right. + + +Available options in the dialog will let you `Copy JSON to Clipboard` and/or `Save JSON to file` which can help you synchronize your dashboard changes back to the provisioning source. +More information in the [Provisioning documentation](/administration/provisioning/). + +
+ +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v5-2.md b/docs/sources/whatsnew/whats-new-in-v5-2.md new file mode 100644 index 0000000..a4f8833 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-2.md @@ -0,0 +1,99 @@ ++++ +title = "What's new in Grafana v5.2" +description = "Feature and improvement highlights for Grafana v5.2" +keywords = ["grafana", "new", "documentation", "5.2", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-2/"] +weight = -16 +[_build] +list = false ++++ + +# What's new in Grafana v5.2 + +Grafana v5.2 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here! +- [Native builds for ARM]({{< relref "#native-builds-for-arm" >}}) native builds of Grafana for many more platforms! +- [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets +- [Security]({{< relref "#security" >}}) make your Grafana instance more secure +- [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements +- [InfluxDB]({{< relref "#influxdb" >}}) now supports the `mode` function +- [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord +- [Dashboards and Panels]({{< relref "#dashboards-panels" >}}) with save and import enhancements + +## Elasticsearch alerting + +{{< docs-imagebox img="/img/docs/v52/elasticsearch_alerting.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.2 ships with an updated Elasticsearch data source with support for alerting. Alerting support for Elasticsearch has been one of +the most requested features by our community and now it's finally here. Please try it out and let us know what you think. + +
+ +## Native builds for ARM + +Grafana v5.2 brings an improved build pipeline with cross-platform support. This enables native builds of Grafana for ARMv7 (x32) and ARM64 (x64). +We've been longing for native ARM build support for ages. With the help from our amazing community this is now finally available. +Please try it out and let us know what you think. + +Another great addition with the improved build pipeline is that binaries for macOS/Darwin (x64) and Windows (x64) are now automatically built and +published for both stable and nightly builds. + +## Improved Docker image + +The Grafana docker image adds support for Docker secrets which enables you to supply Grafana with configuration through files. More +information in the [Installing using Docker documentation](/installation/docker/#reading-secrets-from-files-support-for-docker-secrets). + +## Security + +{{< docs-imagebox img="/img/docs/v52/login_change_password.png" max-width="800px" class="docs-image--right" >}} + +Starting from Grafana v5.2, when you login with the administrator account using the default password you'll be presented with a form to change the password. +We hope this encourages users to follow Grafana's best practices and change the default administrator password. + +
+ +## Prometheus + +The Prometheus data source now aligns the start/end of the query sent to Prometheus with the step, which ensures PromQL expressions with *rate* +functions get consistent results, and thus avoids graphs jumping around on reload. + +## InfluxDB + +The InfluxDB data source now includes support for the *mode* function which returns the most frequent value in a list of field values. + +## Alerting + +By popular demand Grafana now includes support for an alert notification channel type for [Discord](https://discordapp.com/). + +## Dashboards and Panels + +### Modified time range and variables are no longer saved by default + +{{< docs-imagebox img="/img/docs/v52/dashboard_save_modal.png" max-width="800px" class="docs-image--right" >}} + +Starting from Grafana v5.2, a modified time range or variable are no longer saved by default. To save a modified +time range or variable, you'll need to actively select that when saving a dashboard, see screenshot. +This should hopefully make it easier to have same defaults for time and variables in dashboards and make it more explicit +when you actually want to overwrite those settings. + +
+ +### Import dashboard enhancements + +{{< docs-imagebox img="/img/docs/v52/dashboard_import.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.2 adds support for specifying an existing folder or creating a new one when importing a dashboard - a long-awaited feature since +Grafana v5.0 introduced support for dashboard folders and permissions. The import dashboard page has also got some general improvements +and should now make it more clear if a possible import will overwrite an existing dashboard, or not. + +This release also adds some improvements for those users only having editor or admin permissions in certain folders. The links to +*Create Dashboard* and *Import Dashboard* are now available in the side navigation, in dashboard search and on the manage dashboards/folder page for a +user that has editor role in an organization or the edit permission in at least one folder. + +
+ +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v5-3.md b/docs/sources/whatsnew/whats-new-in-v5-3.md new file mode 100644 index 0000000..2befd1e --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-3.md @@ -0,0 +1,90 @@ ++++ +title = "What's new in Grafana v5.3" +description = "Feature and improvement highlights for Grafana v5.3" +keywords = ["grafana", "new", "documentation", "5.3", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-3/"] +weight = -17 +[_build] +list = false ++++ + +# What's new in Grafana v5.3 + +Grafana v5.3 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Google Stackdriver]({{< relref "#google-stackdriver" >}}) as a core data source! +- [TV mode]({{< relref "#tv-and-kiosk-mode" >}}) is improved and more accessible +- [Alerting]({{< relref "#notification-reminders" >}}) with notification reminders +- [Postgres]({{< relref "#postgres-query-builder" >}}) gets a new query builder! +- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for GitLab is improved +- [Annotations]({{< relref "#annotations" >}}) with template variable filtering +- [Variables]({{< relref "#variables" >}}) with free text support + +## Google Stackdriver + +{{< docs-imagebox img="/img/docs/v53/stackdriver-with-heatmap.png" max-width= "600px" class="docs-image--no-shadow docs-image--right" >}} + +Grafana v5.3 ships with built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) and enables you to visualize your Stackdriver metrics in Grafana. + +Getting started with the plugin is easy. Simply create a GCE Service account that has access to the Stackdriver API scope, download the Service Account key file from Google and upload it on the Stackdriver data source configuration page in Grafana and you should have a secure server-to-server authentication setup. Like other core plugins, Stackdriver has built-in support for alerting. It also comes with support for heatmaps and basic variables. + +If you're already accustomed to the Stackdriver Metrics Explorer UI, you'll notice that there are a lot of similarities to the query editor in Grafana. It is possible to add filters using wildcards and regular expressions. You can do Group By, Primary Aggregation and Alignment. + +Alias By allows you to format the legend the way you want, and it's a feature that is not yet present in the Metrics Explorer. Two other features that are only supported in the Grafana plugin are the abilities to manually set the Alignment Period in the query editor and to add Annotations queries. + +The Grafana Stackdriver plugin comes with support for automatic unit detection. Grafana will try to map the Stackdriver unit type to a corresponding unit type in Grafana, and if successful the panel Y-axes will be updated accordingly to display the correct unit of measure. This is the first core plugin to provide support for unit detection, and it is our intention to provide support for this in other core plugins in the near future. + +The data source is still in the `beta` phase, meaning it's currently in active development and is still missing one important feature - templating queries. +Please try it out, but be aware of that it might be subject to changes and possible bugs. We would love to hear your feedback. + +Refer to [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md" >}}) for more detailed information on how to get started and use it. + +## TV and Kiosk Mode + +{{< docs-imagebox img="/img/docs/v53/tv_mode_still.png" max-width="600px" class="docs-image--no-shadow docs-image--right" animated-gif="/img/docs/v53/tv_mode.gif" >}} + +We've improved the TV and kiosk mode to make it easier to use. There's now an icon in the top bar that will let you cycle through the different view modes. + +1. In the first view mode, the sidebar and most of the buttons in the top bar will be hidden. +1. In the second view mode, the top bar is completely hidden so that only the dashboard itself is shown. +1. Hit the escape key to go back to the default view mode. + +When switching view modes, the URL will be updated to reflect the view mode selected. This allows a dashboard to be opened with a +certain view mode enabled. Additionally, this also enables [playlists](/dashboards/playlist) to be started with a certain view mode enabled. + +
+ +## Notification Reminders + +Do you use Grafana alerting and have some notifications that are more important than others? Then it's possible to set reminders so that you continue to be alerted until the problem is fixed. This is done on the notification channel itself and will affect all alerts that use that channel. +For additional examples of why reminders might be useful for you, see [multiple series](/alerting/alerts-overview/#multiple-series). + +For more information about how to enable and configure reminders, refer to [alerting reminders](/alerting/notifications/#send-reminders). + +## Postgres Query Builder + +Grafana 5.3 comes with a new graphical query builder for Postgres. This brings Postgres integration more in line with some of the other data sources and makes it easier for both advanced users and beginners to work with timeseries in Postgres. For more information about Postgres graphical query builder, refer to [query editor]({{< relref "../datasources/postgres/#query-editor" >}}). + +{{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}} + +## Improved OAuth Support for GitLab + +Grafana 5.3 comes with a new OAuth integration for GitLab that enables configuration to only allow users that are a member of certain GitLab groups to authenticate. This makes it possible to use GitLab OAuth with Grafana in a shared environment without giving everyone access to Grafana. +For more information about how to enable and configure OAuth, refer to [Gitlab OAuth](/auth/gitlab/). + +## Annotations + +Grafana 5.3 brings improved support for [native annotations](/dashboards/annotations/#native-annotations) and makes it possible to use template variables when filtering by tags. +For more information about native annotation, refer to [query by tag](/dashboards/annotations/#query-by-tag). + +{{< docs-imagebox img="/img/docs/v53/annotation_tag_filter_variable.png" max-width="600px" >}} + +## Variables + +Grafana 5.3 ships with a brand new variable type named `Text box` which makes it easier and more convenient to provide free text input to a variable. +This new variable type will display as a free text input field with an optional prefilled default value. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v5-4.md b/docs/sources/whatsnew/whats-new-in-v5-4.md new file mode 100644 index 0000000..96312c5 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-4.md @@ -0,0 +1,81 @@ ++++ +title = "What's new in Grafana v5.4" +description = "Feature and improvement highlights for Grafana v5.4" +keywords = ["grafana", "new", "documentation", "5.4", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-4/"] +weight = -18 +[_build] +list = false ++++ + +# What's new in Grafana v5.4 + +Grafana v5.4 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Alerting]({{< relref "#alerting" >}}) Limit false positives with the new `For` setting +- [Google Stackdriver]({{< relref "#google-stackdriver" >}}) Now with support for templating queries +- [MySQL]({{< relref "#mysql-query-builder" >}}) gets a new query builder! +- [Graph Panel]({{< relref "#graph-panel-enhancements" >}}) Highlight time regions and more +- [Team Preferences]({{< relref "#team-preferences" >}}) Give your teams their own home dashboard + +## Alerting + +{{< docs-imagebox img="/img/docs/v54/alerting-for-dark-theme.png" max-width="600px" class="docs-image--right" >}} + +Grafana v5.4 ships with a new alert rule setting named `For` which is great for removing false positives. If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. + +In the screenshot you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`. [Learn more](/alerting/alerts-overview/#for). + +Additionally, there's now support for disable the sending of `OK` alert notifications. [Learn more](/alerting/notifications/#disable-resolve-message). + +
+ +## Google Stackdriver + +{{< docs-imagebox img="/img/docs/v54/stackdriver_template_query.png" max-width="600px" class="docs-image--right" >}} + +Grafana v5.3 included built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) which enables you to visualize your Stackdriver metrics in Grafana. +One important feature missing was support for templating queries. This is now included together with a brand new templating query editor for Stackdriver. + +The Stackdriver templating query editor lets you choose from a set of different Query Types. This will in turn reveal additional drop downs to help you +find, filter and select the templating values you're interested in, see screenshot for details. The templating query editor also supports chaining multiple variables +making it easy to define variables that's dependent on other variables. + +Stackdriver is the first data source which has support for a custom templating query editor. But starting from Grafana v5.4 it's now possible for all data sources, including plugin data sources, to +create their very own templating query editor. + +Additionally, if Grafana is running on a Google Compute Engine (GCE) virtual machine, it is now 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. [Learn more]({{< relref "../datasources/google-cloud-monitoring/_index.md/#using-gce-default-service-account" >}}). + +Please read [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md/" >}}) for more detailed information on how to get started and use it. + +
+ +## MySQL Query Builder + +Grafana v5.4 comes with a new graphical query builder for MySQL. This brings MySQL integration more in line with some of the other data sources and makes it easier for both advanced users and beginners to work with timeseries in MySQL. For more information about MySQL graphical query builder, refer to [query editor]({{< relref "../datasources/mysql/#query-editor" >}}). + +{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" animated-gif="/img/docs/v54/mysql_query.gif" >}} + +## Graph Panel Enhancements + +Grafana v5.4 adds support for highlighting weekdays and/or certain timespans in the graph panel. This should make it easier to compare for example weekends, business hours and/or off work hours. + +{{< docs-imagebox img="/img/docs/v54/graph_time_regions.png" max-width= "800px" >}} + +Additionally, when rendering series as lines in the graph panel, should there be only one data point available for one series so that a connecting line cannot be established, a point will +automatically be rendered for that data point. This should make it easier to understand what's going on when only receiving a single data point. + +{{< docs-imagebox img="/img/docs/v54/graph_dot_single_point.png" max-width= "800px" >}} + +## Team Preferences + +Grafana v5.4 adds support for customizing home dashboard, timezone and theme for teams, in addition to the existing customization on Organization and user Profile level. + +1. Specifying a preference on User Profile level will override preference on Team and/or Organization level +1. Specifying a preference on Team level will override preference on Organization level. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-0.md b/docs/sources/whatsnew/whats-new-in-v6-0.md new file mode 100644 index 0000000..671b27c --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-0.md @@ -0,0 +1,174 @@ ++++ +title = "What's new in Grafana v6.0" +description = "Feature and improvement highlights for Grafana v6.0" +keywords = ["grafana", "new", "documentation", "6.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-0/"] +weight = -19 +[_build] +list = false ++++ + +# What's new in Grafana v6.0 + +This update to Grafana introduces a new way of exploring your data, support for log data, and tons of other features. + +The main highlights are: + +- [Explore]({{< relref "#explore" >}}) - A new query focused workflow for ad-hoc data exploration and troubleshooting. +- [Grafana Loki]({{< relref "#explore-and-grafana-loki" >}}) - Integration with the new open source log aggregation system from Grafana Labs. +- [Gauge Panel]({{< relref "#gauge-panel" >}}) - A new standalone panel for gauges. +- [New Panel Editor UX]({{< relref "#new-panel-editor" >}}) improves panel editing + and enables easy switching between different visualizations. +- [Google Stackdriver data source]({{< relref "#google-stackdriver-data-source" >}}) is out of beta and is officially released. +- [Azure Monitor]({{< relref "#azure-monitor-data-source" >}}) plugin is ported from being an external plugin to be a core data source +- [React Plugin]({{< relref "#react-panels-query-editors" >}}) support enables an easier way to build plugins. +- [Named Colors]({{< relref "#named-colors" >}}) in our new improved color picker. +- [Removal of user session storage]({{< relref "#easier-to-deploy-improved-security" >}}) makes Grafana easier to deploy and improves security. + +## Explore + +{{< docs-imagebox img="/img/docs/v60/explore_prometheus.png" max-width="800px" class="docs-image--right" caption="Screenshot of the new Explore option in the panel menu" >}} + +Grafana's dashboard UI is all about building dashboards for visualization. **Explore** strips away all the dashboard and panel options so that you can focus on the query and metric exploration. Iterate until you have a working query and then think about building a dashboard. You can also jump from a dashboard panel into **Explore** and from there do some ad-hoc query exploration with the panel queries as a starting point. + +For infrastructure monitoring and incident response, you no longer need to switch to other tools to debug what went wrong. **Explore** allows you to dig deeper into your metrics and logs to find the cause. Grafana's new logging data source, [Loki](https://github.com/grafana/loki) is tightly integrated into Explore and allows you to correlate metrics and logs by viewing them side-by-side. + +**Explore** is a new paradigm for Grafana. It creates a new interactive debugging workflow that integrates two pillars +of observability—metrics and logs. Explore works with every data source but for Prometheus we have customized the +query editor and the experience to provide the best possible exploration UX. + +### Explore and Prometheus + +Explore features a new [Prometheus query editor](/explore/#prometheus-specific-features). This new editor has improved autocomplete, metric tree selector, +integrations with the Explore table view for easy label filtering, and useful query hints that can automatically apply +functions to your query. There is also integration between Prometheus and Grafana Loki (see more about Loki below) that +enabled jumping between metrics query and logs query with preserved label filters. + +### Explore splits + +Explore supports splitting the view so you can compare different queries, different data sources and metrics and logs side by side! + +{{< docs-imagebox img="/img/docs/v60/explore_split.png" max-width="800px" caption="Screenshot of the new Explore option in the panel menu" >}} + +
+ +### Explore and Grafana Loki + +The log exploration and visualization features in Explore are available to any data source but are currently only implemented by the new open source log +aggregation system from Grafana Lab called [Grafana Loki](https://github.com/grafana/loki). + +Loki is a horizontally-scalable, highly-available, multi-tenant log aggregation system inspired by Prometheus. It is designed to be very cost effective, as it does not index the contents of the logs, but rather a set of labels for each log stream. The logs from Loki are queried in a similar way to querying with label selectors in Prometheus. It uses labels to group log streams which can be made to match up with your Prometheus labels. + +For more information about Grafana Loki, refer to [Github Grafana Loki](https://github.com/grafana/loki) or [Grafana Labs hosted Loki](https://grafana.com/loki). + +The Explore feature allows you to query logs and features a new log panel. In the near future, we will be adding support +for other log sources to Explore and the next planned integration is Elasticsearch. + +
+ +
+ +
+ +## New Panel Editor + +Grafana v6.0 has a completely redesigned UX around editing panels. You can now resize the visualization area if you want +more space for queries/options and vice versa. You can now also change visualization (panel type) from within the new +panel edit mode. No need to add a new panel to try out different visualizations! Check out the +video below to see the new Panel Editor in action. + +
+ +
+ +
+ +### Gauge Panel + +We have created a new separate Gauge panel as we felt having this visualization be a hidden option in the Singlestat panel +was not ideal. When it supports 100% of the Singlestat Gauge features, we plan to add a migration so all +singlestats that use it become Gauge panels instead. This new panel contains a new **Threshold** editor that we will +continue to refine and start using in other panels. + +{{< docs-imagebox img="/img/docs/v60/gauge_panel.png" max-width="600px" caption="Gauge Panel" >}} + +
+ +### React Panels and Query Editors + +A major part of all the work that has gone into Grafana v6.0 has been on the migration to React. This investment +is part of the future-proofing of Grafana's code base and ecosystem. Starting in v6.0 **Panels** and **Data +source** plugins can be written in React using our published `@grafana/ui` sdk library. More information on this +will be shared soon. + +{{< docs-imagebox img="/img/docs/v60/react_panels.png" max-width="600px" caption="React Panel" >}} +
+ +## Google Stackdriver data source + +Built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) is officially released in Grafana 6.0. Beta support was added in Grafana 5.3 and we have added lots of improvements since then. + +To get started read the guide: [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md/" >}}). + +## Azure Monitor data source + +One of the goals of the Grafana v6.0 release is to add support for the three major clouds. Amazon CloudWatch has been a core data source for years and Google Stackdriver is also now supported. We developed an external plugin for Azure Monitor last year and for this release the [plugin](https://grafana.com/plugins/grafana-azure-monitor-datasource) is being moved into Grafana to be one of the built-in data sources. For users of the external plugin, Grafana will automatically start using the built-in version. As a core data source, the Azure Monitor data source is able to get alerting support, in the 6.0 release alerting is supported for the Azure Monitor service, with the rest to follow. + +The Azure Monitor data source integrates four Azure services with Grafana - Azure Monitor, Azure Log Analytics, Azure Application Insights and Azure Application Insights Analytics. + +Please read [Using Azure Monitor in Grafana documentation]({{< relref "../datasources/azuremonitor/" >}}) for more detailed information on how to get started and use it. + +## Provisioning support for alert notifiers + +Grafana now has support for provisioning alert notifiers from configuration files, allowing operators to provision notifiers without using the UI or the API. A new field called `uid` has been introduced which is a string identifier that the administrator can set themselves. This is the same kind of identifier used for dashboards since v5.0. This feature makes it possible to use the same notifier configuration in multiple environments and refer to notifiers in dashboard json by a string identifier instead of the numeric id which depends on insert order and how many notifiers exist in the instance. + +## Easier to deploy and improved security + +Grafana 6.0 removes the need to configure and set up additional storage for [user sessions](/tutorials/ha_setup/#user-sessions). This should make it easier to deploy and operate Grafana in a +high availability setup and/or if you're using a stateless user session store like Redis, Memcache, Postgres or MySQL. + +Instead of user sessions, we've implemented a solution based on short-lived tokens that are rotated frequently. This also replaces the old "remember me cookie" +solution, which allowed a user to be logged in between browser sessions and which have been subject to several security holes throughout the years. +For more information about the short-lived token solution and how to configure it, refer to [short lived token](/auth/overview/#login-and-short-lived-tokens). + +> Please note that due to these changes, all users will be required to login upon next visit after upgrade. + +Besides these changes we have also made security improvements regarding Cross-Site Request Forgery (CSRF) and Cross-site Scripting (XSS) vulnerabilities: + +- Cookies are per default using the [SameSite](/administration/configuration/#cookie-samesite) attribute to protect against CSRF attacks +- Script tags in text panels are per default [disabled](/administration/configuration/#disable-sanitize-html) to protect against XSS attacks + +> **Note:** If you're using [Auth Proxy Authentication](/auth/auth-proxy/) you still need to have user sessions set up and configured +but our goal is to remove this requirement in the near future. + +## Named Colors + +{{< docs-imagebox img="/img/docs/v60/named_colors.png" max-width="400px" class="docs-image--right" caption="Named Colors" >}} + +We have updated the color picker to show named colors and primary colors. We hope this will improve accessibility and +helps making colors more consistent across dashboards. We hope to do more in this color picker in the future, like showing +colors used in the dashboard. + +Named colors also enables Grafana to adapt colors to the current theme. + +
+ +## Other features + +- The ElasticSearch data source now supports [bucket script pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html). This gives the ability to do per-bucket computations like the difference or ratio between two metrics. +- Support for Google Hangouts Chat alert notifications +- New built in template variables for the current time range in `$__from` and `$__to` + +## Upgrading + +See [upgrade notes](/installation/upgrading/#upgrading-to-v6-0). + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-1.md b/docs/sources/whatsnew/whats-new-in-v6-1.md new file mode 100644 index 0000000..07bf51a --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-1.md @@ -0,0 +1,57 @@ ++++ +title = "What's new in Grafana v6.1" +description = "Feature and improvement highlights for Grafana v6.1" +keywords = ["grafana", "new", "documentation", "6.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-1/"] +weight = -20 +[_build] +list = false ++++ + +# What's new in Grafana v6.1 + +## Highlights + +### Ad hoc Filtering for Prometheus + +{{< imgbox max-width="30%" img="/img/docs/v61/prometheus-ad-hoc.gif" caption="Ad-hoc filters variable for Prometheus" >}} + +The ad hoc filter feature allows you to create new key/value filters on the fly with autocomplete for both key and values. The filter condition is then automatically applied to all queries on the dashboard. This makes it easier to explore your data in a dashboard without changing queries and without having to add new template variables. + +Other timeseries databases with label-based query languages have had this feature for a while. Recently Prometheus added support for fetching label names from their API and thanks to [Mitsuhiro Tanda](https://github.com/mtanda) implementing it in Grafana, the Prometheus data source finally supports ad hoc filtering. + +Support for fetching a list of label names was released in Prometheus v2.6.0 so that is a requirement for this feature to work in Grafana. + +### Permissions: Editors can own dashboards, folders and teams they create + +When the dashboard folders feature and permissions system was released in Grafana 5.0, users with the editor role were not allowed to administrate dashboards, folders or teams. In the 6.1 release, we have added a configuration option that can change the default permissions so that editors are admins for any Dashboard, Folder or Team they create. + +This feature also adds a new Team permission that can be assigned to any user with the editor or viewer role and enables that user to add other users to the Team. + +We believe that this is more in line with the Grafana philosophy, as it will allow teams to be more self-organizing. This option will be made permanent if it gets positive feedback from the community so let us know what you think in the [issue on GitHub](https://github.com/grafana/grafana/issues/15590). + +To turn this feature on add the following [configuration option](/administration/configuration/#editors-can-admin) to your Grafana ini file in the `users` section and then restart the Grafana server: + +```ini +[users] +editors_can_admin = true +``` + +### List and revoke of user auth tokens in the API + +As the first step of a feature to be able to list a user's signed in devices/sessions and to be able log out those devices from the Grafana UI, support has been added to the [API to list and revoke user authentication tokens](/http_api/admin/#auth-tokens-for-user). + +### Minor Features and Fixes + +This release contains a lot of small features and fixes: + +- A new keyboard shortcut `d l` toggles all Graph legends in a dashboard. +- A small bug fix for Elasticsearch - template variables in the alias field now work properly. +- Some new capabilities have been added for data source plugins that will be of interest to plugin authors: + - a new OAuth pass-through option. + - it is now possible to add user details to requests sent to the dataproxy. +- Heatmap and Explore fixes. + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. + +A huge thanks to our community for all the reported issues, bug fixes and feedback. diff --git a/docs/sources/whatsnew/whats-new-in-v6-2.md b/docs/sources/whatsnew/whats-new-in-v6-2.md new file mode 100644 index 0000000..5673761 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-2.md @@ -0,0 +1,94 @@ ++++ +title = "What's new in Grafana v6.2" +description = "Feature and improvement highlights for Grafana v6.2" +keywords = ["grafana", "new", "documentation", "6.2", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-2/"] +weight = -21 +[_build] +list = false ++++ + +# What's new in Grafana v6.2 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +If you use a password for your data sources please read the [upgrade notes](/installation/upgrading/#upgrading-to-v6-2). + +Check out the [demo dashboard](https://play.grafana.org/d/ZvPm55mWk/new-features-in-v6-2?orgId=1) of some the new features in v6.2. + +## Improved security + +Data sources now store passwords and basic auth passwords in `secureJsonData` encrypted by default. Existing data source with unencrypted passwords will keep working. +Read the [upgrade notes](/installation/upgrading/#upgrading-to-v6-2) on how to migrate existing data sources to use encrypted storage. + +To mitigate the risk of [Clickjacking](https://www.owasp.org/index.php/Clickjacking), embedding Grafana is no longer allowed per default. +Read the [upgrade notes](/installation/upgrading/#upgrading-to-v6-2) for further details of how this may affect you. + +To mitigate the risk of sensitive information being cached in browser after a user has logged out, browser caching is now disabled for full page requests. + +## Provisioning + +- Environment variables support, see [Using environment variables](/administration/provisioning/#using-environment-variables) for more information. +- Reload provisioning configs, see [Admin HTTP API](/http_api/admin/#reload-provisioning-configurations) for more information. +- Do not allow deletion of provisioned dashboards +- When trying to delete or save provisioned dashboard, relative file path to the file is shown in the dialog. + +## Official support for Elasticsearch 7 + +Grafana v6.2 ships with official support for Elasticsearch v7, see [Using Elasticsearch in Grafana]({{< relref "../datasources/elasticsearch/#elasticsearch-version" >}}) for more information. + +## Bar Gauge Panel + +Grafana v6.2 ships with a new exciting panel! This new panel, named Bar Gauge, is very similar to the current +Gauge panel and shares almost all it's options. The main difference is that the Bar Gauge uses both horizontal and +vertical space much better and can be more efficiently stacked both vertically and horizontally. The Bar Gauge also +comes with 3 unique display modes, Basic, Gradient, and Retro LED. Read the +[preview article](https://grafana.com/blog/2019/04/11/sneak-preview-of-new-visualizations-coming-to-grafana/) to learn +more about the design and features of this new panel. + +Retro LED display mode +{{< docs-imagebox img="/assets/img/blog/bargauge/bar_gauge_retro_led.jpg" max-width="800px" caption="Bar Gauge LED mode" >}} + +Gradient mode +{{< docs-imagebox img="/assets/img/blog/bargauge/gradient.jpg" max-width="800px" caption="Bar Gauge Gradient mode" >}} + +## Improved table data support + +We have been working on improving table support in our new react panels (Gauge and Bar Gauge) and this is ongoing work +that will eventually come to the new Graph and Singlestat and Table panels we are working on. But you can see it already in +the Gauge and Bar Gauge panels. Without any config, you can visualize any number of columns or choose to visualize each +row as its own gauge. + +## Lazy loading of panels out of view + +This has been one of the most requested features for many years and is now finally here! Lazy loading of panels means +Grafana will not issue any data queries for panels that are not visible. This will greatly reduce the load +on your data source backends when loading dashboards with many panels. + +## Panels without title + +Sometimes your panels do not need a title and having that panel header still take up space makes singlestats and +other panels look strange and have bad vertical centering. In v6.2 Grafana will allow panel content (visualizations) +to use the full panel height in case there is no panel title. + +{{< docs-imagebox img="/img/docs/v62/panels_with_no_title.jpg" max-width="800px" caption="Bar Gauge Gradient mode" >}} + +## Minor Features and Fixes + +This release contains a lot of small features and fixes: + +- Explore - Adds user time zone support, reconnect for failing data sources and a fix that prevents killing Prometheus instances when Histogram metrics are loaded. +- Alerting - Adds support for configuring timeout durations and retries, see [configuration](/administration/configuration/#evaluation-timeout-seconds) for more information. +- Azure Monitor - Adds support for multiple subscriptions per data source. +- Elasticsearch - A small bug fix to properly display percentiles metrics in table panel. +- InfluxDB - Support for POST HTTP verb. +- CloudWatch - Important fix for default alias disappearing in v6.1. +- Search - Works in a scope of dashboard's folder by default when viewing dashboard. + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. + +A huge thanks to our community for all the reported issues, bug fixes and feedback. + +## Upgrading + +Read important [upgrade notes](/installation/upgrading/#upgrading-to-v6-2). diff --git a/docs/sources/whatsnew/whats-new-in-v6-3.md b/docs/sources/whatsnew/whats-new-in-v6-3.md new file mode 100644 index 0000000..20821cc --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-3.md @@ -0,0 +1,144 @@ ++++ +title = "What's new in Grafana v6.3" +description = "Feature and improvement highlights for Grafana v6.3" +keywords = ["grafana", "new", "documentation", "6.3", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-3/"] +weight = -22 +[_build] +list = false ++++ + +# What's new in Grafana v6.3 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +- New Explore features + - [Loki Live Streaming]({{< relref "#loki-live-streaming" >}}) + - [Loki Context Queries]({{< relref "#loki-context-queries" >}}) + - [Elasticsearch Logs Support]({{< relref "#elasticsearch-logs-support" >}}) + - [InfluxDB Logs Support]({{< relref "#influxdb-logs-support" >}}) +- [Data links]({{< relref "#data-links" >}}) +- [New Time Picker]({{< relref "#new-time-picker" >}}) +- [Graph Area Gradients]({{< relref "#graph-gradients" >}}) - A new graph display option! +- Grafana Enterprise + - [LDAP Active Sync]({{< relref "#ldap-active-sync" >}}) - LDAP Active Sync + - [SAML Authentication]({{< relref "#saml-authentication" >}}) - SAML Authentication + +## Explore improvements + +This release adds a ton of enhancements to Explore. Both in terms of new general enhancements but also in +new data source specific features. + +### Loki live streaming + +For log queries using the Loki data source you can now stream logs live directly to the Explore UI. + +### Loki context queries + +After finding a log line through the heavy use of query filters it can then be useful to +see the log lines surrounding the line your searched for. The `show context` feature +allows you to view lines before and after the line of interest. + +### Elasticsearch logs support + +This release adds support for searching and visualizing logs stored in Elasticsearch in the Explore mode. With a special +simplified query interface specifically designed for logs search. + +{{< docs-imagebox img="/img/docs/v63/elasticsearch_explore_logs.png" max-width="600px" caption="New Time Picker" >}} + +Please read [Using Elasticsearch in Grafana]({{< relref "../datasources/elasticsearch/#elasticsearch-version" >}}) for more detailed information on how to get started and use it. + +### InfluxDB logs support + +This release adds support for searching and visualizing logs stored in InfluxDB in the Explore mode. With a special +simplified query interface specifically designed for logs search. + +{{< docs-imagebox img="/img/docs/v63/influxdb_explore_logs.png" max-width="600px" caption="New Time Picker" >}} + +Please read [Using InfluxDB in Grafana]({{< relref "../datasources/influxdb/#querying-logs-beta" >}}) for more detailed information on how to get started and use it. + +## Data Links + +We have simplified the UI for defining panel drilldown links (and renamed them to Panel links). We have also added a +new type of link named `Data link`. The reason to have two different types is to make it clear how they are used +and what variables you can use in the link. Panel links are only shown in the top left corner of +the panel and you cannot reference series name or any data field. + +While `Data links` are used by the actual visualization and can reference data fields. + +Example: +```url +http://my-grafana.com/d/bPCI6VSZz/other-dashboard?var-server=${__series_name} +``` + +You have access to these variables: + +Name | Description +------------ | ------------- +*${__series_name}* | The name of the time series (or table) +*${__value_time}* | The time of the point your clicking on (in millisecond epoch) +*${__url_time_range}* | Interpolates as the full time range (i.e. from=21312323412&to=21312312312) +*${__all_variables}* | Adds all current variables (and current values) to the URL + +You can then click on point in the Graph. + +{{< docs-imagebox img="/img/docs/v63/graph_datalink.png" max-width="400px" caption="New Time Picker" >}} + +For now only the Graph panel supports `Data links` but we hope to add these to many visualizations. + +## New Time Picker + +The time picker has been re-designed and with a more basic design that makes accessing quick ranges more easy. + +{{< docs-imagebox img="/img/docs/v63/time_picker.png" max-width="400px" caption="New Time Picker" >}} + +## Graph Gradients + +Want more eye candy in your graphs? Then the fill gradient option might be for you! Works really well for +graphs with only a single series. + +{{< docs-imagebox img="/img/docs/v63/graph_gradient_area.jpeg" max-width="800px" caption="Graph Gradient Area" >}} + +Looks really nice in light theme as well. + +{{< docs-imagebox img="/img/docs/v63/graph_gradients_white.png" max-width="800px" caption="Graph Gradient Area" >}} + +## Grafana Enterprise + +Substantial refactoring and improvements to the external auth systems has gone in to this release making the features +listed below possible as well as laying a foundation for future enhancements. + +### LDAP Active Sync + +This is a new Enterprise feature that enables background syncing of user information, org role and teams memberships. +This syncing is otherwise only done at login time. With this feature you can schedule how often this user synchronization should +occur. + +For example, lets say a user is removed from an LDAP group. In previous versions of Grafana an admin would have to +wait for the user to logout or the session to expire for the Grafana permissions to update, a process that can take days. + +With active sync the user would be automatically removed from the corresponding team in Grafana or even logged out and disabled if no longer +belonging to an LDAP group that gives them access to Grafana. + +[Read more](/auth/enhanced_ldap/#active-ldap-synchronization). + +### SAML Authentication + +Built-in support for SAML is now available in Grafana Enterprise. + +[See docs]({{< relref "../auth/saml.md" >}}) + +### Team Sync for GitHub OAuth + +When setting up OAuth with GitHub it's now possible to sync GitHub teams with Teams in Grafana. + +[See docs]({{< relref "../auth/github.md" >}}) + +### Team Sync for Auth Proxy + +We've added support for enriching the Auth Proxy headers with Teams information, which makes it possible +to use Team Sync with Auth Proxy. + +[See docs](/auth/auth-proxy/#auth-proxy-authentication). diff --git a/docs/sources/whatsnew/whats-new-in-v6-4.md b/docs/sources/whatsnew/whats-new-in-v6-4.md new file mode 100644 index 0000000..a720b81 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-4.md @@ -0,0 +1,146 @@ ++++ +title = "What's new in Grafana v6.4" +description = "Feature and improvement highlights for Grafana v6.4" +keywords = ["grafana", "new", "documentation", "6.4", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-4/"] +weight = -23 +[_build] +list = false ++++ + +# What's new in Grafana v6.4 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +Grafana 6.4 comes with a lot of new features and enhancements backed with tons of work around the data models and query execution that is going to enable powerful future capabilities. +Some of those new capabilities can already be seen in this release, like sharing query results between panels. + +- [**Explore:** Go back to dashboard (with query changes)]({{< relref "#go-back-to-dashboard-from-explore" >}}) +- [**Explore:** Live tailing improvements]({{< relref "#live-tailing-improvements" >}}) +- **Loki:** Show logs as annotations in dashboard graphs +- **Loki:** Use Loki in dashboard panels +- [**Panels:** New logs panel]({{< relref "#new-logs-panel" >}}) +- [**Panels:** Data links improvements]({{< relref "#data-links-improvements" >}}) +- [**Graph:** Series override to turn constant (point) into a line]({{< relref "#series-override-to turn-constant-into-a-line" >}}) +- [**Dashboard:** Share query results between panels]({{< relref "#share-query-results-between-panels" >}}) +- [**Plugins:** Alpha version of grafana-toolkit]({{< relref "#alpha-version-of-grafana-toolkit" >}}) +- [**Image Rendering:** PhantomJS deprecation]({{< relref "#phantomjs-deprecation" >}}) +- [**Docker:** Alpine based docker image]({{< relref "#alpine-based-docker-image" >}}) +- [**LDAP:** Debug UI]({{< relref "#ldap-debug-ui" >}}) +- [**Enterprise**: Reporting]({{< relref "#reporting" >}}) +- [**Enterprise**: GitLab OAuth Team Sync support]({{< relref "#gitlab-oauth-team-sync-support" >}}) +- [**Enterprise**: Teams and LDAP Improvements]({{< relref "#ldap-teams" >}}) + + +### Go back to dashboard from Explore + +To help accelerate workflows that involve regularly switching from Explore to a dashboard and vice-versa, we've added the ability to return to the origin dashboard +after navigating to Explore from the panel's dropdown. + +{{< docs-imagebox img="/img/docs/v60/explore_panel_menu.png" caption="Screenshot of the new Explore Icon" >}} + +After you've navigated to Explore, you should notice a "Back" button in the Explore toolbar. + + + +Simply clicking the button will return you to the origin dashboard, or, if you'd like to bring changes you make in Explore back to the dashboard, simply click +the arrow next to the button to reveal a "Return to panel with changes" menu item. + + + +### Live tailing improvements + +With 6.4 version you can now pause the live tail view to see the last 1000 lines of logs without being interrupted by new logs coming in. You can either pause manually with pause button or the live tailing will automatically pause when you scroll up to see older logs. To resume you just hit the resume button to continue live tailing. + +We also introduced some performance optimizations to allow live tailing of higher throughput log streams and various UI fixes and improvements like more consistent styling and fresh logs highlighting. + + + +### New Logs Panel + +The logs panel shows log lines from datasources that support logs, e.g., Elastic, Influx, and Loki. Typically you would use this panel next to a graph panel to display the log output of a related process. + + + +Limitations: Even though Live tailing can be enabled on logs panels in dashboards, we recommend using Live tailing in Explore. On dashboards, the refresher at the top of the page should be used instead to keep the data of all panels in sync. Note that the logs panel is still beta and we're looking to get feedback. + +## Data Links improvements + +With Grafana 6.3 we introduced a new way of creating [Data Links](https://grafana.com/blog/2019/08/27/new-in-grafana-6.3-easy-to-use-data-links/). +Grafana 6.4 improves Data Links and adds them to the Gauge and Bar Gauge and panels. + +With Data Links you can define dynamic links to other dashboards and systems. The link can now reference template variables and query results like series name and labels, field name, value and time. + +For more information about Data Links, refer to [data link](https://grafana.com/docs/features/panels/graph/#data-link) + +## Series override to turn constant into a line + +Some graph query results are made up only of one datapoint per series but can be shown in the graph panel with the help of [series overrides](/features/panels/graph/#series-overrides). +To show a horizontal line through the Y-value of the datapoint across the whole graph, add a series override and select `Transform > constant`. + + + +## Share query results between panels + +Grafana 6.4 continues the work started in 6.3 of creating a data model and query execution lifecycle that can support robust analytics and streaming. These changes are mostly structural and lay the foundation for powerful features in future releases. + +The first new feature all these changes have enabled is the ability to share query results between panels. So for example if you have an expensive query you can visualize the same results in a graph, table and singlestat panel. To reuse another panel’s query result select the data source named `-- Dashboard --` and then select the panel. + +To make the sharing of query results even more powerful we are introducing a transformation step as well that allows you to select specific parts of the query result and transform it. This new transformation feature is in [alpha](https://grafana.com/docs/administration/configuration/#enable-alpha) state and has to be enabled in the config file. + +DataFrame, our primary data model, has now a [columnar](https://en.wikipedia.org/wiki/Column-oriented_DBMS) layout. This +will support easier frontend processing. The DataSource query interface has been updated to better support streaming. +The result can now either return a `Promise` or `Observable`. Be on the lookout for more on live data +streaming in the future! + +## Alpha version of grafana-toolkit + +[grafana-toolkit](https://www.npmjs.com/package/@grafana/toolkit/v/6.4.0-beta.1) is our attempt to simplify the life of plugin developers. It’s a CLI that helps them focus on the core value of their plugin rather than the ceremony around setting up the environment, configs, tests and builds. It’s available as an NPM package under `next` tag. + +You can read more about the grafana-toolkit [in the Readme](https://github.com/grafana/grafana/blob/master/packages/grafana-toolkit/README.md) and play with it by trying out our [react panel](https://github.com/grafana/simple-react-panel) or [angular panel](https://github.com/grafana/simple-angular-panel) templates. + +## PhantomJS deprecation + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, have been deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. + +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). + +## Alpine-based Docker image + +Grafana’s Docker image is now based on Alpine 3.10 and should from now on report zero vulnerabilities when scanning the image for security vulnerabilities. + +## LDAP Debug UI + +After listening to customer feedback, we have been working at improving the experience to set up authentication and synchronization with LDAP. We're happy to present the new LDAP Debug View. + +You'll be able to see how a user authenticating with LDAP would be mapped and whether your LDAP integration is working correctly. Furthermore, it provides a simpler method to test your integration with LDAP server(s) and have a clear view of how attributes are mapped between both systems. + +The feature is currently limited to Grafana Server Admins. + +For more information on how to use this new feature, follow the [guide]({{< relref "../auth/ldap.md#ldap-debug-view" >}}). + +## Grafana Enterprise + +### Reporting + +A common request from Enterprise users have been to be able to set up reporting for Grafana, and now it’s here. A report is simply a PDF of a Grafana dashboard, outside of just generating a PDF you can set up a schedule so that you can get the report emailed to yourself (or whoever is interested) whenever it suits you. + +This feature is currently limited to Organization Admins. + +{{< docs-imagebox img="/img/docs/v64/reports.jpeg" max-width="500px" caption="Reporting" >}} + +### GitLab OAuth Team Sync support + +GitLab OAuth gets support for Team Sync, making it possible to synchronize your GitLab Groups with Teams in Grafana. + +[Read more about Team Sync](https://grafana.com/docs/auth/team-sync/). + +## Upgrading + +See [upgrade notes](/installation/upgrading/#upgrading-to-v6-4). + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-5.md b/docs/sources/whatsnew/whats-new-in-v6-5.md new file mode 100644 index 0000000..43412b3 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-5.md @@ -0,0 +1,209 @@ ++++ +title = "What's new in Grafana v6.5" +description = "Feature and improvement highlights for Grafana v6.5" +keywords = ["grafana", "new", "documentation", "6.5", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-5/"] +weight = -24 +[_build] +list = false ++++ + +# What's new in Grafana v6.5 + +For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +Grafana 6.5 comes with a lot of new features and enhancements: + +- [**Docker:** Ubuntu-based images and more]({{< relref "#ubuntu-based-docker-images" >}}) +- [**CloudWatch:** Major rewrite and lots of enhancements]({{< relref "#cloudwatch-data-source-improvements" >}}) +- [**Templating:** Dynamic typeahead queries using $__searchFilter]({{< relref "#dynamic-typeahead-support-in-query-variables" >}}) +- [**Graphite:** Support for additional Metrictank functionality]({{< relref "#graphite-support-for-additional-metrictank-functionality" >}}) +- [**Explore:** New log row details view]({{< relref "#explore-logs-log-row-details" >}}) +- [**Explore:** Turn parts of log message into a link using derived fields]({{< relref "#loki-explore-derived-fields" >}}) +- [**Explore:** Time-sync of split views]({{< relref "#time-sync-of-split-views-in-explore" >}}) +- [**Explore**: Hover/tooltip support in graphs]({{< relref "#explore-metrics-graph-hover-tooltip" >}}) +- [**Azure Monitor**: Alerting support for Azure Application Insights]({{< relref "#alerting-support-for-azure-application-insights" >}}) +- [**Provisioning**: Allow saving of provisioned dashboards from UI]({{< relref "#allow-saving-of-provisioned-dashboards-from-ui" >}}) +- [**Auth Proxy:** Mix auth proxy with Grafana login token and session cookie]({{< relref "#mix-auth-proxy-with-grafana-login-token-and-session-cookie" >}}) +- [**OAuth:** Generic OAuth now supports role mapping]({{< relref "#generic-oauth-role-mapping" >}}) +- [**Image Rendering:** Quick update since Grafana 6.4]({{< relref "#image-renderer-plugin" >}}) + +### Ubuntu-based Docker images + +In Grafana [v6.4]({{< relref "whats-new-in-v6-4/#alpine-based-docker-image" >}}), we switched the Grafana Docker image from Ubuntu to Alpine. This change provides a more secure and lightweight Docker image. + +This change has received both negative and positive feedback as well as some bug reports. We learned that switching to an Alpine-based Docker image was a big breaking change for a lot of users. We should have more clearly highlighted this in blog post, release notes, changelog, and the [Docker Hub readme](https://hub.docker.com/r/grafana/grafana). + +We also broke the Docker images for ARM, but this is fixed in Grafana v6.5. + +Grafana Docker images should be as secure as possible by default and that’s why the Alpine-based Docker images will continue to be the Grafana default (`grafana/grafana:`). With that said, it’s good to give users options, and that’s why starting from Grafana v6.5, Ubuntu-based Docker images are also (`grafana/grafana:-ubuntu`) available. + +Read more about [Installing using Docker]({{< relref "../installation/docker/" >}}). + +### CloudWatch data source improvements + +In this release, several feature improvements and additions were made in the CloudWatch data source. This work has been done in collaboration with the Amazon CloudWatch team. + +#### GetMetricData API + +For Grafana version 6.5 or higher, all API requests to GetMetricStatistics have been replaced with calls to GetMetricData, following Amazon’s [best practice to use the GetMetricData API](https://aws.amazon.com/premiumsupport/knowledge-center/cloudwatch-getmetricdata-api) instead of GetMetricStatistics, because data can be retrieved faster at scale with GetMetricData. This change provides better support for CloudWatch metric math and enables the use of automatic search expressions. + +While GetMetricStatistics qualified for the CloudWatch API free tier, this is not the case for GetMetricData calls. For more information, please refer to the [CloudWatch pricing page](https://aws.amazon.com/cloudwatch/pricing/). + +#### Dynamic queries using dimension wildcards + +In Grafana 6.5 or higher, you can monitor a dynamic list of metrics by using the asterisk (\*) wildcard for one or more dimension values. + +{{< docs-imagebox img="/img/docs/v65/cloudwatch-dimension-wildcard.png" max-width="800px" class="docs-image--right" caption="CloudWatch dimension wildcard" >}} + +The example queries all metrics in the namespace `AWS/EC2` with a metric name of `CPUUtilization` and _any_ value for the `InstanceId` dimension. This can help you monitor metrics for AWS resources, like EC2 instances or containers. For example, when new instances get created as part of an auto scaling event, they automatically appear in the graph without you having to track new instance IDs. You can click `Show Query Preview` to see the search expression that is automatically built to support wildcards. To learn more about search expressions, visit the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html). + +By default, the search expression is defined in such a way that the queried metrics must match the defined dimension names exactly. This means that in the example it only returns metrics with exactly one dimension with name ‘InstanceId’. + +You can untoggle `Match Exact` to include metrics that have other dimensions defined. Turning off `Match Exact` also creates a search expression even if you don’t use wildcards. We simply search for any metric that match at least the namespace, metric name, and all defined dimensions. + +#### Deep linking from Grafana panels to the CloudWatch console + +{{< docs-imagebox img="/img/docs/v65/cloudwatch-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch deep linking" >}} + +Left-clicking a time series in the panel displays a context menu with a link to `View in CloudWatch console`. Clicking that link opens the CloudWatch console and displays all the metrics for that query. If you are not currently logged in to the CloudWatch console, then the link opens the login page. The link is valid for any account, but it only displays the right metrics if you are logged in to the account that corresponds to the selected data source in Grafana. + +This feature is not available for metrics based on math expressions. + +#### Improved feedback when throttling occurs + +If the [limit of the GetMetricData API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html) is reached, either the transactions per second limit or the data points per second limit, then a throttling error will be returned by the CloudWatch API. Throttling limits are defined per account and region, so the alert modal indicates which data source got throttled in which region. A link to request a limit increase for the affected region is provided, but you will have to log in to the correct account. For example, for us-east-1, a limit increase can be requested on [AWS console](https://console.aws.amazon.com/servicequotas/home?region=us-east-1#!/services/monitoring/quotas/L-5E141212). + +#### Multi-value template variables now use search expressions + +When defining dimension values based on multi-valued template variables, we now use search expressions to query for the matching metrics. This enables the use of multiple template variables in one query and also allows you to use template variables for queries that have the `Match Exact` option disabled. + +Search expressions are currently limited to 1024 characters, so your query may fail if you have a long list of values. We recommend using the asterisk (\*) wildcard instead of the `All` option if you want to query all metrics that have any value for a certain dimension name. + +The use of multi-valued template variables is only supported for dimension values. Using multi-valued template variables for `Region`, `Namespace`, or `Metric Name` is not supported. + +#### Curated Dashboards + +The updated CloudWatch data source is shipped with pre-configured dashboards for five of the most popular AWS services: + +- Amazon Elastic Compute Cloud `Amazon EC2` +- Amazon Elastic Block Store `Amazon EBS` +- AWS Lambda `AWS Lambda` +- Amazon CloudWatch Logs `Amazon CloudWatch Logs` +- Amazon Relational Database Service `Amazon RDS` + +To import the pre-configured dashboards, go to the configuration page of your CloudWatch data source and click on the `Dashboards` tab. Click `Import` for the dashboard you would like to use. To customize the dashboard, we recommend to save the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released. + +{{< docs-imagebox img="/img/docs/v65/cloudwatch-dashboard-import.png" max-width="600px" caption="CloudWatch dashboard import" >}} + +### Dynamic typeahead support in query variables + +If you have a query variable that has many thousands of values it can be quite slow to search for a specific value in the dropdown. This is due to the fact that all that search filtering is happening in the browser. + +Using `__searchFilter` in the template variable query field you can filter the query results based on what the user types in the variable dropdown input. When nothing has been entered by the user the default value for `__searchFilter` is `*` , `.*` or `%` depending on data source and formatting option. + +The example below shows how to use `__searchFilter` as part of the query field to enable searching for `server` while the user types in the dropdown select box. + +Query + +```bash +apps.$app.servers.$__searchFilter +``` + +TagValues + +```bash +tag_values(server, server=~${__searchFilter:regex}) +``` + +This feature is currently only supported by [Graphite]({{< relref "../datasources/graphite/#using-searchfilter-to-filter-results-in-query-variable" >}}), [MySQL]({{< relref "../datasources/mysql/#using-searchfilter-to-filter-results-in-query-variable" >}}) and [Postgres]({{< relref "../datasources/postgres/#using-searchfilter-to-filter-results-in-query-variable" >}}) data sources. + +### Graphite: Support for additional Metrictank functionality + +The Graphite data source now has an option to enable extra functionality when using [Metrictank](https://grafana.com/oss/metrictank/) as a Graphite datastore. +In the Datasource configuration for Graphite, you can change the type to Metrictank. +Metrictank returns 2 kinds of additional metadata along its responses: + +- **Performance information:** Time spent querying index, fetching data, running processing functions, the number of series and points fetched, cache hits/misses, etc. This can be useful for optimizing queries or tuning the chunk cache. +- **Lineage information about the returned series:** Which archive was fetched from (raw or rollup), which (if any) runtime consolidation was applied (using which processing function), etc. This is very useful information for anyone trying to understand how their data was generated and why it may not look as expected. + +To see the metadata response from Metrictank you can inspect the response using the Query Inspector found in the panel queries tab. +Grafana 6.5 includes a new `Panel Inspector` in alpha/preview where you also can see the metadata response from Metrictank. +You can try it out by enabling a feature flag in the Grafana configuration file: + +```bash +[feature_toggles] +enable = inspect +``` + +{{< docs-imagebox img="/img/docs/v65/panel-inspector.png" max-width="400px" caption="New Panel Inspector modal" >}} + +In Grafana 6.6, this will have a more user friendly display. In the future, additional Metrictank functionality will become available when the Graphite datasource option is set to the `Metrictank` type. + +### Explore/Metrics: Graph hover/tooltip + +We finally got around to implementing the series hover that shows values of the timeseries you hover over. This has been a requested feature ever since Explore was released. The graph component has been rewritten from scratch, making it more composable for future interactions with the graph data. + +{{< docs-imagebox img="/img/docs/v65/explore_tooltip.png" max-width="500px" caption="Explore graph tooltip/hover" >}} + +### Explore/Logs: Log row details + +We have massively simplified the way we display both log row labels/fields as well as parsed fields by putting them into an extendable area in each row. + +So far labels had been squashed into their own column, making long label values difficult to read or interact with. Similarly, the parsed fields (available for logfmt and JSON structured logs) were too fiddly for mouse interaction. To solve this we took both and put them into a collapsed area below each row for more robust interaction. We have also added the ability to filter out labels, i.e., turn them into a negative filter on click (in addition to a positive filter). + +{{< docs-imagebox img="/img/docs/v65/explore_log_details.gif" caption="Explore Log row details" >}} + +### Loki/Explore: Derived fields + +Derived fields allow any part of a log message to be turned into a link. Leaning on the concept of data links for graphs, we've extended the log result viewer in Explore to turn certain parsed fields into a link, based on a pattern to match. + +This allows you to turn an occurrence of e.g., `traceId=624f706351956b81` in your log line, into a link to your distributed tracing system to view that trace. The configuration for the patterns to match can be found in the datasource settings. + +This release starts with support for Loki, but we will bring this concept to other data sources soon. + +### Time-sync of split views in Explore + +In the Explore split view, you can now link the two timepickers so that if you change one, the other gets changed as well. This helps with keeping start and end times of the split view queries in sync and will ensure that you're looking at the same time interval in both split panes. + +{{< docs-imagebox img="/img/docs/v65/explore_time_sync.gif" caption="Time-sync of split views in Explore" >}} + +### Alerting support for Azure Application Insights + +The [Azure Monitor]({{< relref "../datasources/azuremonitor/" >}}) data source supports multiple services in the Azure cloud. Before Grafana v6.5, only the Azure Monitor service had support for [Grafana Alerting]({{< relref "../alerting" >}}). In Grafana 6.5, alerting support has been implemented for the [Application Insights service]({{< relref "../datasources/azuremonitor/#querying-the-application-insights-service" >}}). + +### Allow saving of provisioned dashboards from UI + +Historically it has been possible to make changes to a provisioned dashboard in the Grafana UI. However, it hasn't been possible to save the changes without manual intervention. In Grafana 6.5 we introduce a new dashboard provisioning setting named `allowUiUpdates`. If `allowUiUpdates` is set to `true` and you make changes to a provisioned dashboard, you can save the dashboard and the changes will be persisted to the Grafana database. + +Read more about this new feature in [Provisioning Grafana]({{< relref "../administration/provisioning/#making-changes-to-a-provisioned-dashboard" >}}). + +### Mix auth proxy with Grafana login token and session cookie + +With the new setting, `enable_login_token`, set to true Grafana will, after successful auth proxy header validation, assign the user a login token and cookie. You only have to configure your auth proxy to provide headers for the /login route. Requests via other routes will be authenticated using the cookie. + +Read more about this new feature in [Auth Proxy Authentication]({{< relref "../auth/auth-proxy/#login-token-and-session-cookie" >}}) + +### Generic OAuth role mapping + +Grafana 6.5 makes it possible to configure Generic OAuth to map a certain response from OAuth provider to a certain Grafana organization role, similar to the existing [LDAP Group Mappings]({{< relref "../auth/ldap/#group-mappings" >}}) feature. The new setting is named `role_attribute_path` and expects a [JMESPath](http://jmespath.org/) expression. + +Read more about this new feature in [Generic OAuth Authentication]({{< relref "../auth/generic-oauth/" >}}) and make sure to check out the [JMESPath examples]({{< relref "../auth/generic-oauth/#jmespath-examples" >}}). + +### Image renderer plugin + +Since we announced the deprecation of PhantomJS and the new [Image Renderer Plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) in Grafana [6.4]({{< relref "whats-new-in-v6-4/#phantomjs-deprecation" >}}), we’ve received bug reports and valuable feedback. + +In Grafana 6.5 we’ve updated documentation to make it easier to understand how to install and troubleshoot possible problems. Read more about [Image Rendering]({{< relref "../administration/image_rendering/" >}}). + +Please try the [Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) and let us know what you think. + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading/#upgrading-to-v6-5" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-6.md b/docs/sources/whatsnew/whats-new-in-v6-6.md new file mode 100644 index 0000000..b3bd7b8 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-6.md @@ -0,0 +1,218 @@ ++++ +title = "What's new in Grafana v6.6" +description = "Feature and improvement highlights for Grafana v6.6" +keywords = ["grafana", "new", "documentation", "6.6", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-6/"] +weight = -25 +[_build] +list = false ++++ + +# What's new in Grafana v6.6 + +For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +Grafana 6.6 comes with a lot of new features and enhancements: + +- [**Panels:** New stat panel]({{< relref "#new-stat-panel" >}}) +- [**Panels:** Auto min/max for Bar Gauge/Gauge/Stat]({{< relref "#auto-min-max" >}}) +- [**Panels:** News panel]({{< relref "#news-panel" >}}) +- [**Panels:** Custom data units]({{< relref "#custom-data-units" >}}) +- [**Panels:** Bar Gauge unfilled option]({{< relref "#bar-gauge-unfilled-option" >}}) +- [**TimePicker:** New design & features]({{< relref "#new-time-picker" >}}) +- [**Alerting enhancements**]({{< relref "#alerting-enhancements" >}}) +- [**Explore:** Added log message line wrapping options for logs]({{< relref "#explore-logs-panel-log-message-line-wrapping-options" >}}) +- [**Explore:** Column with unique log labels ]({{< relref "#explore-logs-panel-column-with-unique-log-labels" >}}) +- [**Explore:** Context tooltip]({{< relref "#explore-context-tooltip" >}}) +- **Explore:** Added ability to specify step with Prometheus queries +- **Graphite:** Added Metrictank dashboard to Graphite datasource +- **Loki:** Support for template variable queries +- **Postgres/MySQL/MSSQL:** Added support for region annotations +- [**Security:** Added disabled option for cookie sameSite attribute]({{< relref "#cookie-management-modifications" >}}) +- **TablePanel, GraphPanel:** Exclude hidden columns from CSV +- [**Enterprise:** White labeling]({{< relref "#enterprise-white-labeling" >}}) +- [**Enterprise:** APT and YUM repositories]({{< relref "#enterprise-apt-and-yum-repositories" >}}) +- [**Stackdriver:** Meta labels]({{< relref "#stackdriver-meta-labels" >}}) +- [**CloudWatch:** Calculate period based on time range]({{< relref "#cloudwatch-calculate-period-based-on-time-range" >}}) +- [**CloudWatch:** Display partial result in graph when max DP/call limit is reached]({{< relref "#cloudwatch-display-partial-result-in-graph-when-max-data-points-per-call-limit-is-reached" >}}) + +## New stat panel + +{{< docs-imagebox img="/img/docs/v66/stat_panel_dark2.png" max-width="1024px" caption="Stat panel" >}} + +This release adds a new panel named `Stat`. This panel is designed to replace the current `Singlestat` as the primary way to show big single number panels along with a sparkline. This panel is of course building on our new panel infrastructure and option design. So, you can use the new threshold UI and data links. It also supports the same repeating feature as the Gauge and Bar Gauge panels, meaning it will repeat a separate visualization for every series or row +in the query result. + +Key features: + +- Automatic font size handling +- Automatic layout handling based on panel size +- Colors based on thresholds that adapt to light or dark theme +- Data links support +- Repeats horizontally or vertically for every series, row, or column + +Here is how it looks in light theme: + +{{< docs-imagebox img="/img/docs/v66/stat_panel_light.png" max-width="1024px" caption="Stat panel" >}} + +## Auto min-max + +For the panels Gauge, Bar Gauge, and Stat, you can now leave the min and max settings empty. Grafana will, in that case, calculate the min and max based on all the data. + +## News panel + +This panel shows RSS feeds as news items in the default home dashboard for v6.6. Add it to your custom home dashboards to keep up-to-date with Grafana news, or switch the default RSS feed to one of your choice. + +{{< docs-imagebox img="/img/docs/v66/news_panel.png" max-width="600px" caption="News panel" >}} + +## Custom data units + +A top feature request for years is now finally here. All panels now support custom units. Type any text in the unit picker and select the `Custom: ` option. By default, the text will be used as a suffix unit. If you want a custom prefix, then type `prefix: ` to make the custom unit appear before the value. If you want a custom SI unit (with auto SI suffixes) specify `si:Ups`. A value like 1000 will be rendered as `1 kUps`. + +{{< docs-imagebox img="/img/docs/v66/custom_unit_burger1.png" max-width="600px" caption="Custom unit" >}} + +You can also paste a native emoji in the unit picker and pick it as a custom unit: + +{{< docs-imagebox img="/img/docs/v66/custom_unit_burger2.png" max-width="600px" caption="Custom unit emoji" >}} + +## Bar Gauge unfilled option + +The Bar Gauge visualization has a new display option: `Unfilled`. This new option is enabled by default, so it will change how this visualization is displayed on old dashboards. If you prefer the old default -- in which an unfilled area is not shown, and the value follows directly after -- you have to update the visualization settings. +{{< docs-imagebox img="/img/docs/v66/bar_gauge_unfilled.png" max-width="900px" caption="Bar gauge unfilled" >}} + +## New time picker + +The time picker has gotten a major design update. Key changes: + +- Quickly access the absolute from and to input fields without an extra click. +- Calendar automatically shows when from or to inputs have focus. +- A single calendar view can be used to select and show the from and to date. +- You can now select recent absolute ranges. + +{{< docs-imagebox img="/img/docs/v66/time_picker_update.png" max-width="700px" caption="New time picker" >}} + +## Alerting enhancements + +- We have introduced a new configuration for enforcing a minimal interval between evaluations to reduce load on the backend. +- The email notifier can now optionally send a single email to all recipients. +- OpsGenie, PagerDuty, Threema, and Google Chat notifiers have been updated to send additional information. + +## Cookie management modifications + +In order to align with a [change in Chrome 80](https://www.chromestatus.com/feature/5088147346030592), a breaking change has been introduced to Grafana's [`cookie_samesite` setting]({{< relref "../administration/configuration.md#cookie-samesite" >}}). Grafana now properly renders cookies with the `SameSite=None` attribute when this setting is `none`. The previous behavior of `none` was to omit the `SameSite` attribute from cookies. Grafana will use the previous behavior when `cookie_samesite` is set to `disabled`. + +Read more about this in the [upgrade notes]({{< relref "../installation/upgrading/#important-changes-regarding-samesite-cookie-attribute" >}}). + +## Explore/Logs Panel: Log message line wrapping options + +We introduced the wrap-lines option for logs because as for some of our users feel it's more efficient to see one line per log message. The wrapped-line option is set as a default; the unwrapped setting results in horizontal scrolling. + +{{< docs-imagebox img="/img/docs/v66/explore_wrap_lines.gif" max-width="600px" caption="Log message line wrapping" >}} + +## Explore/Logs Panel: Column with unique log labels + +After feedback from our community, we have decided to reintroduce a labels column. However, for better readability and usefulness, we have transformed it into a Unique labels column which includes only non-common labels. All common labels are displayed above. + +{{< docs-imagebox img="/img/docs/v66/explore_labels_column.png" max-width="600px" caption="Unique log labels column" >}} + +## Explore: Context tooltip + +Isolating a series from a big set of lines in a graph is important for drill-downs. That's why we have implemented the context tooltip in Explore, which allows you to copy data and labels from it to further refine the query. + +{{< docs-imagebox img="/img/docs/v66/explore_context_tooltip.png" max-width="600px" caption="Explore context tooltip" >}} + +## Enterprise: White labeling + +This release adds new white labeling options to the grafana.ini file (can also be set via ENV variables). + +```bash +[white_labeling] +# Set to complete URL to override login logo +login_logo = https://my.logo.url/images/logo.png + +# Set to complete css background expression to override login background +login_background = url(http://www.bhmpics.com/wallpapers/starfield-1920x1080.jpg) + +# Set to complete URL to override menu logo +menu_logo = https://my.logo.url/images/logo_icon.png + +# Set to complete URL to override fav icon (icon shown in browser tab) +fav_icon = https://my.logo.url/images/logo_icon_32px.png + +# Set to complete URL to override apple/ios icon +apple_touch_icon = https://my.logo.url/images/logo_icon_32px.png + +# Below is an example for how to replace the default footer & help links with 2 custom links +footer_links = support guides +footer_links_support_text = Support +footer_links_support_url = http://your.support.site +footer_links_guides_text = Guides +footer_links_guides_url = http://your.guides.site +``` + +Customize the login page, side menu bar, and footer links. + +{{< docs-imagebox img="/img/docs/v66/whitelabeling_1.png" max-width="700px" caption="White labeling example" >}} + +## Enterprise APT and YUM repositories + +Now you can install the enterprise edition from the APT and YUM repository. The following table shows the APT repository for each Grafana version (for instructions read the [installation notes]({{< relref "../installation/debian/#install-from-apt-repository" >}})) : + +| Grafana Version | Package | Repository | +|-----------------|---------|------------| +| Grafana OSS | grafana | `https://packages.grafana.com/oss/deb stable main` | +| Grafana OSS (Beta) | grafana | `https://packages.grafana.com/oss/deb beta main` | +| Grafana Enterprise | grafana-enterprise | `https://packages.grafana.com/enterprise/deb stable main` | +| Grafana Enterprise (Beta) | grafana-enterprise | `https://packages.grafana.com/enterprise/deb beta main` | + +The following table shows the YUM repositories for each Grafana version (for instructions read the [installation notes]({{< relref "../installation/rpm/#install-from-yum-repository" >}})) : + +| Grafana Version | Package | Repository | +|----------------------------|--------------------|----------------------------------------------------| +| Grafana OSS | grafana | `https://packages.grafana.com/oss/rpm` | +| Grafana OSS (Beta) | grafana | `https://packages.grafana.com/oss/rpm-beta` | +| Grafana Enterprise | grafana-enterprise | `https://packages.grafana.com/enterprise/rpm` | +| Grafana Enterprise (Beta) | grafana-enterprise | `https://packages.grafana.com/enterprise/rpm-beta` | + +We recommend all users to install the Enterprise Edition of Grafana, which can be seamlessly upgraded with a Grafana Enterprise [subscription](https://grafana.com/products/enterprise/?utm_source=grafana-install-page). + +## Stackdriver: Meta labels + +From now on it will be possible to utilize meta data label in "group bys", filters and in the alias field. Unfortunately, there's no API to retrieve all the labels, but the group by field dropdown comes with a pre-defined list of common system labels. User labels cannot be pre-defined, but it's possible to enter them manually in the group by field. If a meta data label, user label or system label, is included in the group by segment, it will be possible to create filters based on it and to expand its value on the alias field. + +{{< docs-imagebox img="/img/docs/v66/metadatalabels.gif" max-width="800px" caption="Stackdriver meta labels" >}} + +## CloudWatch: Calculate period based on time range + +When the period field was left blank in Grafana 6.5, it would default to 60 seconds. In case users issued queries with a large time span, there was a high risk that they would reach the 100,800 data points per request limit in the Get Metric Data (GMD) API. When the period field is left blank in Grafana 6.6, the period will be calculated automatically based on the time range. The formula that is used is `time range in seconds / 2000`, and then we snap to next higher value in an array of pre-defined periods `[60, 300, 900, 3600, 21600, 86400]`. This will reduce the risk for receiving a `Too many datapoints requested` error in the panel. + +## CloudWatch: Display partial result in graph when max data points per call limit is reached + +In case all queries in a GMD call are metric stat (not using math expressions), Grafana will paginate the response until all data points are received. But pagination is not supported in case a math expression is being used, so in that case it's not possible to receive more than 100,800 data points. Previously when that limit was reached, we only displayed an error message. In Grafana 6.6, we also display the 100,800 data points that were received in the graph. + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading/#upgrading-to-v6-6" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. + +## Notice about upcoming changes in backendSrv for plugin authors + +In our mission to migrate away from AngularJS to React we have removed all AngularJS dependencies in the core data retrieval service `backendSrv`. This change is already in master and will be introduced in the next `major` Grafana release. + +Removing the AngularJS dependencies in `backendSrv` has the unfortunate side effect of AngularJS digest no longer being triggered for any request made with `backendSrv`. Because of this, external plugins using `backendSrv` directly may suffer from strange behaviour in the UI. + +To remedy this issue as a plugin author you need to trigger the digest after a direct call to `backendSrv`. + +Example: + +```js +backendSrv.get(‘http://your.url/api’).then(result => { + this.result = result; + this.$scope.$digest(); +}); +``` diff --git a/docs/sources/whatsnew/whats-new-in-v6-7.md b/docs/sources/whatsnew/whats-new-in-v6-7.md new file mode 100644 index 0000000..07b2fd2 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-7.md @@ -0,0 +1,94 @@ ++++ +title = "What's New in Grafana v6.7" +description = "Feature and improvement highlights for Grafana v6.7" +keywords = ["grafana", "new", "documentation", "6.7", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-7/"] +weight = -26 +[_build] +list = false ++++ + +# What's new in Grafana v6.7 + +This topic includes the release notes for the Grafana v6.7. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +Grafana 6.7 comes with a lot of new features and enhancements: + +- [**Dashboard:** Enforce minimum refresh interval]({{< relref "#enforce-minimum-dashboard-refresh-interval" >}}) +- **Data source:** Google Sheets data source +- [**Explore:** Query history]({{< relref "#query-history" >}}) +- [**Authorization:** Azure OAuth]({{< relref "#azure-oauth" >}}) +- [**Stackdriver:** Project Selector]({{< relref "#stackdriver-project-selector" >}}) +- [**Enterprise:** White Labeling for application title]({{< relref "#white-labeling-for-application-title" >}}) +- [**Enterprise:** Reporting configuration for timeout and concurrency]({{< relref "#reporting-configuration-for-timeout-and-concurrency" >}}) +- [**Enterprise:** Export dashboard as pdf]({{< relref "#export-dashboard-as-pdf" >}}) +- [**Enterprise:** Report landscape mode]({{< relref "#report-landscape-mode" >}}) +- [**Enterprise:** Azure OAuth Team Sync support]({{< relref "#azure-oauth-team-sync-support" >}}) + +## General features + +General features are included in all Grafana editions. + +### Query history +> BETA: Query history is a beta feature. It is local to your browser and is not shared with others. + +Query history is a new feature that lets you view and interact with the queries that you have previously run in Explore. You can add queries to the Explore query editor, write comments, create and share URL links, star your favorite queries, and much more. Starred queries are displayed in Starred tab, so it is easier to reuse queries that you run often without typing them from scratch. + +Learn more about query history in [Explore]({{< relref "../explore" >}}). + +{{< docs-imagebox img="/img/docs/v67/rich-history.gif" max-width="1024px" caption="Query history" >}} + +### Azure OAuth +Grafana v6.7 comes with a new OAuth integration for Microsoft Azure Active Directory. You can now assign users and groups to Grafana roles from the Azure Portal. Learn how to enable and configure it in [Azure AD OAuth2 authentication]({{< relref "../auth/azuread/" >}}). + +### Enforce minimum dashboard refresh interval + +Allowing a low dashboard refresh interval can cause severe load on data sources and Grafana. Grafana v6.7 allows you to restrict the dashboard refresh interval so it cannot be set lower than a given interval. This provides a way for administrators to control dashboard refresh behavior on a global level. + +Refer to min_refresh_interval in [Configuration]({{< relref "../administration/configuration#min-refresh-interval" >}}) for more information and how to enable this feature. + +### Stackdriver project selector + +A Stackdriver data source in Grafana is configured for one service account only. That service account is always associated with a default project in Google Cloud Platform (GCP). Depending on your setup in GCP, the service account might be granted access to more projects than just the default project. + +In Grafana 6.7, the query editor has been enhanced with a project selector that makes it possible to query different projects without changing datasource. Many thanks [Eraac](https://github.com/Eraac), [eliaslaouiti](https://github.com/eliaslaouiti), and [NaurisSadovskis](https://github.com/NaurisSadovskis) for making this happen! + +## Grafana Enterprise features + +General features are included in the Grafana Enterprise edition software. + +### White labeling customizes application title +This release adds a new white labeling option to customize the application title. Learn how to configure it in [White labeling]({{< relref "../enterprise/white-labeling/" >}}). + +``` +[white_labeling] +# Set to your company name to override application title +app_title = Your Company +``` + +### Configure reporting for timeout and concurrency + +This release adds more configuration for the reporting feature rendering requests. You can set the panel rendering request timeout and the maximum number of concurrent calls to the rendering service in your configuration. Learn how to do it in [Reporting]({{< relref "../enterprise/reporting/" >}}). + +``` +[reporting] +# Set timeout for each panel rendering request +rendering_timeout = 10s +# Set maximum number of concurrent calls to the rendering service +concurrent_render_limit = 10 +``` + +### Export dashboard as PDF + +This feature allows you to export a dashboard as a PDF document. All dashboard panels will be rendered as images and added into the PDF document. Learn more in [Export dashboard as PDF]({{< relref "../enterprise/export-pdf/" >}}). + +### Report landscape mode + +You can now use either portrait or landscape mode in your reports. Portrait will render three panels per page and landscape two. +{{< docs-imagebox img="/img/docs/enterprise/reports_create_new.png" max-width="1024px" caption="New report" >}} + +[Reporting]({{< relref "../enterprise/reporting/" >}}) has been updated as a result of this change. + +### Azure OAuth Team Sync support +When setting up OAuth with Microsoft Azure AD, you can now sync Azure groups with Teams in Grafana. +Learn more in [Team sync]({{< relref "../enterprise/team-sync/" >}}). diff --git a/docs/sources/whatsnew/whats-new-in-v7-0.md b/docs/sources/whatsnew/whats-new-in-v7-0.md new file mode 100644 index 0000000..9a2139d --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-0.md @@ -0,0 +1,229 @@ ++++ +title = "What's New in Grafana v7.0" +description = "Feature and improvement highlights for Grafana v7" +keywords = ["grafana", "new", "documentation", "7.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-0/"] +weight = -27 +[_build] +list = false ++++ + +# What's new in Grafana v7.0 + +This topic includes the release notes for Grafana v7.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +This major release of Grafana is the next step in our Observability story. It includes powerful new features for manipulating, transforming, and doing math on data. Grafana Enterprise has the first version of Usage analytics, which will help Grafana Admins better manage large, corporate Grafana ecosystems. + +The Grafana 7.0 stable release is scheduled for the 18th of May. In the meantime, if you want to know more about what we've been up to and what is coming, sign up for our online GrafanaCon conference. + +[{{< figure src="/assets/img/blog/GrafanaCONline.jpg" max-width="800px" lightbox="false" caption="GrafanaCONline May 13-29" >}}](https://grafana.com/about/events/grafanacon/2020/?source=blog) + +The main highlights are: + +- [**New Panel Editor** Redesign based on community feedback.]({{< relref "#new-panel-editor-and-unified-data-model" >}}) +- [**Explore** New tracing UI and support for visualizing Jaeger and Zipkin traces.]({{< relref "#new-tracing-ui" >}}) +- [**Enterprise** Usage insights, Presence indicator, and Auth improvements.]({{< relref "#grafana-enterprise" >}}) +- [**Transformations** Transformations and simple Math operations for all data sources.]({{< relref "#transformations" >}}) +- [**Field overrides** Automatically configure panels with data from queries.]({{< relref "#field-options-and-overrides" >}}) +- [**Table** New Table panel.]({{< relref "#table-panel" >}}) +- [**Plugins** New plugins platform.]({{< relref "#plugins-platform" >}}) +- [**Tutorials** New tutorials section.]({{< relref "#new-tutorials" >}}) +- [**Cloudwatch** Support for Cloudwatch Logs in Explore and the Logs panel.]({{< relref "#cloudwatch-logs" >}}) +- [**Breaking change** PhantomJS removed.]({{< relref "#breaking-change-phantomjs-removed" >}}) +- [**Time zones** Time zone support]({{< relref "#time-zone-support" >}}) + +## New panel editor and unified data model + +We have redesigned the UI for editing panels. The first visible change is that we have separated panel display settings to a right-hand side pane that you can collapse or expand depending on what your focus is on. With this change we are also introducing our new unified option model and UI for defining data configuration and display options. This unified data configuration system powers a consistent UI for setting data options across visualizations, as well as making all data display settings data driven and overridable. + +This new option architecture and UI will make all panels have a consistent set of options and behaviors for attributes like unit, min, max, thresholds, links, decimals. Not only that but all these options will share a consistent UI for specifying override rules and is extensible for custom panel specific options. + +In previous versions of Grafana, each visualization had slightly different ways to define their options. One immediate benefit is after setting options like units or thresholds in a panel, you can seamlessly switch between visualization types and keep those options. This will bring increased ease of use and more consistency for users and plugin developers. + +We have yet to migrate all core panels to this new architecture so in 7.0 there will be some inconsistencies in the UI between panels. This will be fixed soon in future releases as we update all the core panels and help the community update the community panel plugins. + +Learn more about this feature in [Panel editor]({{< relref "../panels/panel-editor.md" >}}). + +## New tracing UI + +This release adds major support for distributed tracing, including a telemetry mode to complement the existing support for metrics and logs. Traces allow you to follow how single requests travel through a distributed system. We are starting with an integrated trace viewer and two new built-in data sources: Jaeger and Zipkin. + +You can use the new trace view in Explore either directly to search for a particular trace or you can configure Loki to detect trace IDs in the log lines and link directly to a trace timeline pulled from Jaeger or Zipkin data source. + +In the future we will add more workflows and integrations so that correlating between metrics, logs and traces is even easier. + +{{< docs-imagebox img="/img/docs/v70/tracing_ui.png" max-width="1024px" caption="Tracing UI" >}} + +## Transformations + +The data you want to visualize can come from many different places and it is usually not in exactly the right form. Users can now transform non-time series data into tables (e.g., JSON files or even simple lookup tables) in seconds without any customization or additional overhead. They can then combine non-time series data with any other data in Grafana; data from an external database or a panel that already exists in one of their current dashboards. + +By chaining a simple set of point and click [transformations]({{< relref "../panels/transformations/_index.md" >}}), users will be able join, pivot, filter, re-name and do calculations to get the results they need. Perfect for operations across queries or data sources missing essential data transformations. + +[Transformations]({{< relref "../panels/transformations/_index.md" >}}) also adds the ability to do maths across queries. Lots of data sources do not support this natively, so being able to do it in Grafana is a powerful feature. + +For users with large dashboards or with heavy queries, being able to reuse the query result from one panel in another panel can be a huge performance gain for slow queries (e.g log or sql queries). From the data source menu in the query editor, you can choose the `--dashboard--` option and then choose the query result from another panel on the same dashboard. + +The [Google Sheets data source](https://grafana.com/grafana/plugins/grafana-googlesheets-datasource) that was published a few weeks ago works really well together with the transformations feature. + +We are also introducing a new shared data model for both time series and table data that we call [DataFrame]({{< relref "../developers/plugins/data-frames/#data-frames" >}}). A DataFrame is like a table with columns but we refer to columns as fields. A time series is a DataFrame with two fields (time & value). + +**Transformations shipping in 7.0** + +- **Reduce:** Reduce all rows or data points to a single value using a function like max, min, mean or last. +- **Filter by name:** Removes part of the query results using a regex pattern. The pattern can be inclusive or exclusive. +- **Filter data by query** Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel. +- **Organize fields:** Allows the user to re-order, hide, or rename fields / columns. Useful when data source doesn't allow overrides for visualizing data. +- **Labels to fields:** Groups series by time and returns labels or tags as fields. Useful for showing time series with labels in a table where each label key becomes a separate column. +- **Outer join:** Joins many time series/tables by a field. This can be used to outer join multiple time series on the _time_ field to show many time series in one table. +- **Add field from calculation:** This is a powerful transformation that allows you perform many different types of math operations and add the result as a new field. Can be used to calculate the difference between two series or fields and add the result to a new field. Or multiply one field with another and add the result to a new field. + +Learn more about this feature in [Transformations]({{< relref "../panels/transformations/_index.md" >}}). + +## Field options and overrides + +With Grafana 7.0 we are introducing a new, unified data configuration system that powers a consistent UI for setting data options across visualizations as well as making all data display settings data driven and overridable. This new option architecture and UI will make all panels have a consistent set of options and behaviors for attributes like `unit`, `min`, `max`, `thresholds`, `links`, `decimals` or `value mappings`. Not only that but all these options will share a consistent UI for specifying override rules and is extensible for custom panel specific options. + +Up until now the overrides were available only for Graph and Table panel(via Column Styles), but with 7.0 they work consistently across all visualization types and plugins. + +This feature enables even more powerful visualizations and fine grained control over how the data is displayed. + +Learn more about this feature in [Field options]({{< relref "../panels/field-options/_index.md" >}}). + +## Inspect panels and export data to CSV + +{{< docs-imagebox img="/img/docs/v70/panel_edit_export_raw_data.png" max-width="800px" class="docs-image--right" caption="Panel Edit - Export raw data to CSV" >}} + +Another new feature of Grafana 7.0 is the panel inspector. Inspect allows you to view the raw data for any Grafana panel as well as export that data to a CSV file. With Panel inspect you will also be able to perform simple raw data transformations like join, view query stats or detailed execution data. + +Learn more about this feature in [Inspect a panel]({{< relref "../panels/inspect-panel.md" >}}). + +
+ +## Table panel + +Grafana 7.0 comes with a new table panel (and deprecates the old one). This new table panel supports horizontal scrolling and column resize. Paired with the new `Organize fields` transformation detailed above you can reorder, hide & rename columns. This new panel also supports new cell display modes, like showing a bar gauge inside a cell. + +{{< youtube J29wILRh3QQ >}} +
+ +## Auto grid mode for Stat panel and Gauge + +This new 7.0 feature is for the gauge and stat panels. Before, stat and gauge only supported horizontal or vertical stacking: The auto layout mode just selected vertical or horizontal stacking based on the panel dimensions (whatever was highest). But in 7.0 the auto layout for these two panels will allow dynamic grid layouts where Grafana will try to optimize the usage of space and lay out each sub-visualization in a grid. + +{{< youtube noq1rLGvsrU >}} +
+ +## Cloudwatch Logs + +Grafana 7.0 adds logging support to one of our most popular cloud provider data sources. Autocomplete support for Cloudwatch Logs queries is included for improved productivity. There is support for deep linking to the CloudWatch Logs Insights console for log queries, similar to the deep linking feature for Cloudwatch metrics. Since CloudWatch Logs queries can return time series data, for example through the use of the `stats` command, alerting is supported too. + +## Plugins platform + +The [platform for plugins]({{< relref "../developers/plugins/" >}}) has been completely re-imagined and provides ready-made components and tooling to help both inexperienced and experienced developers get up and running more quickly. The tooling, documentation, and new components will improve plugin quality and reduce long-term maintenance. We are already seeing that a high quality plugin with the Grafana look and feel can be written in much fewer lines of code than previously. + +Learn more about developing plugins in the new framework in [Build a plugin]({{< relref "../developers/plugins/_index.md" >}}). + +### Front end plugins platform + +In Grafana 7.0 we are maturing our panel and front-end datasource plugins platform. + +Plugins can use the same React components that the Grafana team uses to build Grafana. Using these components means the Grafana team will support and improve them continually and make your plugin as polished as the rest of Grafana’s UI. The new [`@grafana/ui` components library](https://developers.grafana.com/ui) is documented with Storybook (visual documentation) and is available on NPM. + +The `@grafana/data`, `@grafana/runtime`, `@grafana/e2e packages` (also available via NPM) aim to simplify the way plugins are developed. We want to deliver a set of [reliable APIs](https://grafana.com/docs/grafana/latest/packages_api/) for plugin developers. + +With [@grafana/toolkit](https://www.npmjs.com/package/@grafana/toolkit) we are delivering a simple CLI that helps plugin authors quickly scaffold, develop and test their plugins without worrying about configuration details. A plugin author no longer needs to be a grunt or webpack expert to build their plugin. + +### Support for backend plugins + +Grafana now officially supports [backend plugins]({{< relref "../developers/plugins/backend/_index.md" >}}) and the first type of plugin to be introduced is a backend component for data source plugins. You can optionally add a backend component to your data source plugin and implement the query logic there to automatically enable alerting in Grafana for your plugin. In the 7.0 release, we introduce the [Grafana Plugin SDK for Go]({{< relref "../developers/plugins/backend/grafana-plugin-sdk-for-go.md" >}}) that enables and simplifies building a backend plugin in [Go](https://golang.org/). + +Plugins can be monitored with the new metrics and health check capabilities. The new Resources capability means backend components can return non-time series data like JSON or static resources like images and opens up Grafana for new use cases. + +With this release, we are deprecating the unofficial first version of backend plugins which will be removed in a future release. + +To learn more, start with the [overview]({{< relref "../developers/plugins/backend/_index.md" >}}). Next, in this [tutorial](https://grafana.com/tutorials/build-a-data-source-backend-plugin/) you'll learn how to build a backend for a data source plugin and enable it for use with [Grafana Alerting]({{< relref "../alerting/_index.md" >}}). Make sure to keep an eye out for additional documentation and tutorials that will be published after the Grafana v7.0 release. + +## New tutorials + +To help you get started with Grafana, we’ve launched a brand new tutorials platform. We’ll continue to expand the platform with more tutorials, but here are some of the ones you can try out now: + +- [Grafana fundamentals](https://grafana.com/tutorials/grafana-fundamentals/) +- [Create users and teams](https://grafana.com/tutorials/create-users-and-teams/) +- [Build a panel plugin](https://grafana.com/tutorials/build-a-panel-plugin/) +- [Build a data source plugin](https://grafana.com/tutorials/build-a-data-source-plugin/) + +## Rollup indicator for Metrictank queries + +{{< docs-imagebox img="/img/docs/v70/metrictank_rollup_metadata.png" max-width="800px" class="docs-image--right" caption="Metrictank rollup metadata" >}} + +Depending on the cardinality of the data and the time range MetricTank may return rolled up (aggregated) data. This can be as subtle as potentially only 1 or 2 graphs out of nine being rolled up. The new rollup indicator is visible in the panel title and you can also inspect extensive metadata and stats about the Metrictank query result and its rollups. + +
+ +## Breaking change - PhantomJS removed + +[PhantomJS](https://phantomjs.org/), have been used for rendering images of dashboards and panels and have been included with Grafana since Grafana v2.0. Since then we’ve had a lot of related bugs and security related issues, mainly due to the fact that PhantomJS have struggled with supporting modern web technologies. Throughout the years, maintaining PhantomJS support in Grafana has been a nightmare. Removing support for PhantomJS has been a high priority for the Grafana project and got stressed even more when the PhantomJS maintainer in March 2018 [announced](https://github.com/ariya/phantomjs/issues/15344) the end of the project. + +Since then we have been working towards removing PhantomJS. In October 2019, when Grafana v6.4 was released, we [announced](https://grafana.com/blog/2019/10/02/grafana-v6.4-released/#phantomjs-deprecation) the deprecation of PhantomJS. Grafana v7.0 removes all PhantomJS support which means that Grafana distribution no longer will include a built-in image renderer. + +As a replacement for PhantomJS we’ve developed the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) which is a plugin that runs on the backend and handles rendering panels and dashboards as PNG images using headless Chromium/Chrome. The [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) can either be installed as a Grafana plugin running in its own process side-by-side with Grafana. or runs as an external HTTP service, hosted using Docker or as a standalone application. + +Read more about [Image Rendering]({{< relref "../administration/image_rendering/" >}}) in the documentation for further instructions. + +## Query history in Explore out of beta + +The Query history feature lets you view and interact with the queries that you have previously run in Explore. You can add queries to the Explore query editor, write comments, create and share URL links, star your favorite queries, and much more. Starred queries are displayed in the Starred tab, so it is easier to reuse queries that you run often without typing them from scratch. + +It was released as a beta feature in Grafana 6.7. The feedback has been really positive and it is now out of beta for the 7.0 release. Learn more about query history in [Explore]({{< relref "../explore" >}}). + +## Stackdriver data source supports Service Monitoring + +[Service monitoring](https://cloud.google.com/service-monitoring) in Google Cloud Platform (GCP) enables you to monitor based on Service Level Objectives (SLOs) for your GCP services. The new SLO query builder in the Stackdriver data source allows you to display SLO data in Grafana. Read more about it in the [Stackdriver data source documentation]({{< relref "../datasources/google-cloud-monitoring/_index.md/#slo-service-level-objective-queries" >}}). + +## Time zone support + +You can now override the [time zone]({{< relref "../dashboards/time-range-controls/#dashboard-time-settings" >}}) used to display date and time values in a dashboard. One benefit of this is that you can specify the local time zone of the service or system that you are monitoring which can be helpful when monitoring a system or service that operates across several time zones. + +## Alerting and deep linking for Azure Log Analytics + +The Azure Monitor data source supports multiple Azure services. Log Analytics queries in the data source now have alerting support too (Azure Monitor and Application Insights already had alerting support). + +A new feature is [deep linking from the graph panel to the Log Analytics query editor in the Azure Portal]({{< relref "../datasources/azuremonitor/#deep-linking-from-grafana-panels-to-the-log-analytics-query-editor-in-azure-portal" >}}). Click on a time series in the panel to see a context menu with a link to View in Azure Portal. Clicking that link opens the Azure Log Analytics query editor in the Azure Portal and runs the query from the Grafana panel. + +## Grafana Enterprise + +Grafana Enterprise focuses on solving problems for large companies and Grafana installations. And in Grafana 7.0 we are finally +solving one of the most common problems of using Grafana at scale. + +This includes problems like: + +- There are too many dashboards, how do I find the right one? +- How to find popular dashboards +- How to find dashboards with errors +- How to identify dashboards that are not being used +- Who created or last viewed this dashboard? + +{{< docs-imagebox img="/img/docs/v70/dashboard_insights_users.png" max-width="1024px" caption="Dashboard Insights Users" >}} + +### Usage insights and Presence indicator + +This release includes a series of features that build on our new usage analytics engine. This “Grafana about Grafana” feature will help our large customers get better insight into the behavior and utilization of their users, dashboards, and data sources. The improved [dashboard search]({{< relref "../enterprise/usage-insights/#improved-dashboard-search" >}}) allows you to sort dashboards by usage and errors. When a user opens a dashboard, they will see a [presence indicator]({{< relref "../enterprise/usage-insights/#presence-indicator" >}}) of who else is viewing the same dashboard. And finally [Dashboard insights]({{< relref "../enterprise/usage-insights/#dashboard-insights" >}}) allows you to view recent dashboard usage. + +{{< docs-imagebox img="/img/docs/v70/presence_indicator.jpg" max-width="1024px" caption="Grafana Enterprise - Presence indicator" >}} + +### SAML Role and Team Sync + +SAML support in Grafana Enterprise is improved by adding Role and Team Sync. Read more about how to use these features in the [SAML team sync documentation]({{< relref "../enterprise/saml.md#configure-team-sync" >}}). + +### Okta OAuth Team Sync + +Okta gets its own provider which adds support for Team Sync. Read more about it in the [Okta documentation]({{< relref "../auth/okta.md" >}}). + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading/#upgrading-to-v7-0" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-1.md b/docs/sources/whatsnew/whats-new-in-v7-1.md new file mode 100644 index 0000000..cd49fbf --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-1.md @@ -0,0 +1,112 @@ ++++ +title = "What's New in Grafana v7.1" +description = "Feature and improvement highlights for Grafana v7.1" +keywords = ["grafana", "new", "documentation", "7.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-1/"] +weight = -28 +[_build] +list = false ++++ + +# What's new in Grafana v7.1 + +This topic includes the release notes for the Grafana v7.1. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +The main highlights are: + +- [**Flux and InfluxDB 2.x support in the Influx Datasource**]({{< relref "#influx-datasource" >}}) +- [**Query history search**]({{< relref "#query-history-search" >}}) +- [**Unification of Explore modes**]({{< relref "#explore-modes-unified" >}}) +- [**Elasticsearch- link to another data source from Explore**]({{< relref "#internal-links-for-elasticsearch" >}}) +- [**Merge on time transform for the new table panel**]({{< relref "#transformations" >}}) +- [**Stat panel text mode**]({{< relref "#stat-panel-text-mode" >}}) +- [**Time range picker update**]({{< relref "#time-range-picker-update" >}}) +- [**Provisioning of apps**]({{< relref "#provisioning-of-apps" >}}) +- [**Azure Monitor Datasource**]({{< relref "#azure-monitor-datasource" >}}) +- [**Deep linking for Google Cloud Monitoring (formerly named Google Stackdriver) datasource**]({{< relref "#deep-linking-for-google-cloud-monitoring-formerly-named-google-stackdriver-datasource" >}}) +- [**Grafana Enterprise features**]({{< relref "#grafana-enterprise-features" >}}) + - [**Secret management with HashiCorp Vault**]({{< relref "#support-for-hashicorp-vault" >}}) + - [**Monthly schedules in reports**]({{< relref "#support-for-monthly-schedules-in-reports" >}}) + +## Influx data source + +Support for Flux and Influx v2 has been added. The InfluxData blog post, [How to Build Grafana Dashboards with InfluxDB, Flux and InfluxQL](https://www.influxdata.com/blog/how-grafana-dashboard-influxdb-flux-influxql/) explains the changes in depth. + +## Query history search + +In Grafana v 7.1 we are introducing search functionality in Query history. You can search across queries and your comments. It is especially useful in combination with a time filter and data source filter. Read more about [Query history here]({{< relref "../explore/_index.md#query-history" >}}). + +{{< docs-imagebox img="/img/docs/v71/query_history_search.gif" max-width="800px" caption="Query history search" >}} + +## Explore modes unified + +Grafana 7.1 includes a major change to Explore: it removes the query mode selector. + +Many data sources tell Grafana whether a response contains time series data or logs data. Using this information, Explore chooses which visualization to use for that data. This means that you don't need to switch back and forth between Logs and Metrics modes depending on the type of query that you want to make. + +## Internal links for Elasticsearch + +The new internal linking feature for Elasticsearch allows you to link to other data sources from your logs. You can now create links in Elastic configuration that point to another data source (similar to an existing feature in Loki). An example would be using a traceID field from your logs to be able to link to traces in a tracing data source like Jaeger. + +## Transformations + +We have added a new **Merge on time** transform that can combine many time series or table results. Unlike the join transform, this combines the result into one table even when the time values do not align / match. + +The new table panel introduced in 7.0 was missing a few features that the old table panel had. This feature, along with ad hoc filtering, means that the new table panel has achieved feature parity with the old table panel. + +## Ad hoc filtering in the new table panel + +[Ad hoc filtering]({{}}), a way to automatically add filters to queries without having to define template variables is now supported in the new Table panel. + +## Stat panel text mode + +The [stat panel]({{}}) has a new **Text mode** option to control what text to show. + +By default, the Stat panel displays: + +- Just the value for a single series or field. +- Both the value and name for multiple series or fields. + +You can use the Text mode option to control what text the panel renders. If the value is not important, only name and color is, then change the `Text mode` to **Name**. The value will still be used to determine color and is displayed in a tooltip. + +{{< docs-imagebox img="/img/docs/v71/stat-panel-text-modes.png" max-width="1025px" caption="Stat panel" >}} + +## Provisioning of apps + +Grafana v7.1 adds support for provisioning of app plugins. This allows app plugins to be configured and enabled/disabled using configuration files. For more information about provisioning of app, refer to [provisioning plugin]({{}}). + +## Azure Monitor data source + +Support for multiple dimensions has been added to all services in the Azure Monitor datasource. This means you can now group by more than one dimension with time series queries. With the Kusto based services, Log Analytics and Application Insights Analytics, you can also select multiple metrics as well as multiple dimensions. + +Additionally, the Raw Edit mode for Application Insights Analytics has been replaced with a new service in the drop down for the data source and is called Insights Analytics. The new query editor behaves in the same way as Log Analytics. + +## Deep linking for Google Cloud Monitoring (formerly named Google Stackdriver) data source + +A new feature in Grafana 7.1 is [deep linking from Grafana panels to the Metrics Explorer in Google Cloud Console]({{}}). Click on a time series in the panel to see a context menu with a link to View in Metrics explorer in Google Cloud Console. Clicking that link opens the Metrics explorer in the Monitoring Google Cloud Console and runs the query from the Grafana panel there. + +## Time range picker update + +With 7.1 we are updating the dashboard's time range picker to allow time zone selection. You no longer need to go to dashboard settings to change the dashboard's time zone. + +The time zone picker itself also got UX improvements. Now you can search for the timezone using country or city name, time zone abbreviations, or UTC offsets. + +## Grafana Enterprise features + +General features are included in the Grafana Enterprise edition software. + +### Support for HashiCorp Vault + +You can now use HashiCorp Vault to get secrets for configuration and provisioning of Grafana Enterprise. For more information about HashiCorp Vault, refer to [vault]({{}}). + +### Support for monthly schedules in reports + +With Grafana Enterprise 7.1, you can generate reports on a [monthly schedule]({{}}). + +## Upgrading + +See [upgrade notes]({{}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-2.md b/docs/sources/whatsnew/whats-new-in-v7-2.md new file mode 100644 index 0000000..ab6af41 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-2.md @@ -0,0 +1,162 @@ ++++ +title = "What's New in Grafana v7.2" +description = "Feature and improvement highlights for Grafana v7.2" +keywords = ["grafana", "new", "documentation", "7.2", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-2/"] +weight = -29 +[_build] +list = false ++++ + +# What's new in Grafana v7.2 + +This topic includes the release notes for the Grafana v7.2. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +The main highlights are: + +- [**New date formatting options added**]({{< relref "#new=date-formatting-options-added" >}}) +- [**Field options are out of beta!**]({{< relref "#field-options-are-out-of-beta" >}}) + - [**Added table column filters**]({{< relref "#added-table-column-filters" >}}) + - [**New field override selection options**]({{< relref "#new-field-override-selection-options" >}}) +- [**New transformations and enhancements**]({{< relref "#new-transformations-and-enhancements" >}}) +- [**Drag to reorder queries**]({{< relref "#drag-to-reorder-queries" >}}) +- [**Inspect queries in Explore**]({{< relref "#inspect-queries-in-explore" >}}) +- [**$__rate_interval for Prometheus**]({{< relref "#__rate_interval-for-prometheus" >}}) +- [**Toggle parsed log fields**]({{< relref "#toggle-parsed-log-fields" >}}) +- [**Sensitive alert channel settings are now encrypted**]({{< relref "#sensitive-alert-channel-settings-are-now-encrypted" >}}) +- [**Grafana Enterprise features**]({{< relref "#grafana-enterprise-features" >}}) + - [**Report time range**]({{< relref "#report-time-range" >}}) + - [**Organization-wide report settings**]({{< relref "#organization-wide-report-settings" >}}) + - [**Report grid layout**]({{< relref "#report-grid-layout" >}}) +- [**What's new in other parts of the Grafana ecosystem**]({{< relref "#whats-new-in-other-parts-of-the-grafana-ecosystem">}}) + - [**ADX (Azure Data Explorer) plugin**]({{< relref "#adx-azure-data-explorer-plugin">}}) + - [**X-Ray data source plugin**]({{< relref "#x-ray-data-source-plugin" >}}) + +## New date formatting options added + +You can now customize how dates are formatted in Grafana. Custom date formats apply to the time range picker, graphs, and other panel visualizations. + +This screenshot shows both a custom full date format with a 12 hour clock and am/pm suffix. The graph is also showing the same 12-hour clock format and a customized month and day format compared to the Grafana default `MM/DD` format. + +{{< docs-imagebox img="/img/docs/v72/date_formats.png" max-width="800px" caption="Custom date time formats" >}} + +Date formats are set for a Grafana instance by adjusting [server-wide settings]({{< relref "../administration/configuration.md#date_formats" >}}) in the Grafana configuration file. We hope to add org- and user-level settings in the future. + +``` +[date_formats] +full_date = MMM Do, YYYY @ hh:mm:ss a +interval_second = hh:mm:ss a +interval_minute = hh:mm a +interval_hour = MMM DD hh:mm a +interval_day = MMM DD +interval_month = YYYY-MM +interval_year = YYYY +``` + +There is also experimental support to use the browser location and language to dynamically change the current date format for each user. This feature is disabled by default. + +The [Configuration]({{< relref "../administration/configuration.md#date_formats" >}}) topic has been updated as a result of this change. + +## Field options are out of beta! + +After lots of testing and user feedback, we removed the beta label from the configuration options in the Field and Override tabs. This release also includes the following feature enhancements. + +### Added table column filters + +You can now dynamically apply value filters to any table column. This option can be enabled for all columns or one specific column using an override rule. + +{{< docs-imagebox img="/img/docs/v72/table_column_filters.png" max-width="800px" caption="Table column filters" >}} + +[Filter table columns]({{< relref "../panels/visualizations/table/filter-table-columns.md" >}}) has been added as a result of this feature. + +### New field override selection options + +You can now add override rules that use a regex matcher to choose which fields to apply rules to. + +The [Field options]({{< relref "../panels/field-options/_index.md" >}}) content and [Configure specific fields]({{< relref "../panels/field-options/configure-specific-fields.md" >}}) have been updated as a result of these changes. + +## New transformations and enhancements + +Grafana 7.2 includes the following transformation enhancements: + +- A new [Group By]({{< relref "../panels/transformations/types-options.md#group-by">}}) transformation that allows you to group by multiple fields and add any number of aggregations for other fields. +- The [Labels to fields]({{< relref "../panels/transformations/types-options.md#labels-to-fields">}}) transformation now allows you to pick one label and use that as the name of the value field. +- You can drag transformations to reorder them. Remember that transformations are processed in the order they are listed in the UI, so think before you move something! + +{{< docs-imagebox img="/img/docs/v72/transformations.gif" max-width="800px" caption="Group by and reordering of transformations" >}} + +## Drag to reorder queries + +The up and down arrows, which were previously the only way to change query order, have been removed. Instead, there is now a grab icon that allows you to drag and drop queries in a list to change their order. + +{{< docs-imagebox img="/img/docs/v72/drag-queries.gif" max-width="800px" caption="Drag to reorder queries" >}} + +The [Queries]({{< relref "../panels/queries.md" >}}) topic has been updated as a result of this change. + +## Inspect queries in Explore + +You can enjoy all the details query inspector gave you in dashboards now in Explore as well. You can open query inspector tab with the button next to query history. See [Query inspector in Explore]({{< relref "../explore/_index.md#query-inspector" >}}) for more details. + +## \$\_\_rate_interval for Prometheus + +You can now use the new variable `$__rate_interval` in Prometheus for rate functions mainly. `$__rate_interval` in general is one scrape interval larger than `$__interval` but is never smaller than four times the scrape interval (which is 15s by default). See the [Prometheus data source]({{< relref "../datasources/prometheus.md#using-__rate_interval-variable" >}}) for more details. + +## Toggle parsed log fields + +With this awesome contribution from one of our community members, you can now toggle parsed fields in Explore if your logs are structured in `json` or `logfmt`. + +{{< docs-imagebox img="/img/docs/v72/explore-toggle-parsed-fields.gif" max-width="800px" caption="Toggling parsed fields in Explore" >}} + +The [Toggle parsed fields]({{< relref "../explore/_index.md#toggle-detected-fields" >}}) section has been added to [Explore]({{< relref "../explore/_index.md" >}}) as a result of this feature. + +## Sensitive alert channel settings are now encrypted + +Alert notification channels now store sensitive settings and secrets, such as API tokens and passwords, encrypted in the database. + +Please read the [upgrade notes]({{< relref "../installation/upgrading.md#ensure-encryption-of-existing-alert-notification-channel-secrets" >}}) for more information and how to migrate. + +## Grafana Enterprise features + +These features are included in the Grafana Enterprise edition software. + +### Report and export dashboards in grid layout + +A new layout option is available when rendering reports: the grid layout. With this option, your report uses the panel layout from your dashboard, so that what you see is what you get. Learn more about the [grid layout]({{< relref "../enterprise/reporting.md#layout-and-orientation" >}}) in the documentation. + +The grid layout is also available for the [Export dashboard as PDF]({{< relref "../enterprise/export-pdf.md" >}}) feature. + +{{< docs-imagebox img="/img/docs/enterprise/reports_grid_landscape_preview.png" max-width="500px" class="docs-image--no-shadow" >}} + +### Report time range + +You can now generate a report with a different time range from the dashboard it is based on. This means that you no longer have to apply workarounds, such as copying dashboards or carefully aligning report generation with the end of the month, to generate reports that cover the period you want. + +For more information, refer to [Reports time range]({{< relref "../enterprise/reporting.md#report-time-range" >}}). + +### Organization-wide report settings + +You can now configure organization-wide report settings, such as report branding, in the Settings tab on the Reporting page. Settings are applied to all the reports of your current organization. + +{{< docs-imagebox img="/img/docs/enterprise/reports_settings.png" max-width="500px" class="docs-image--no-shadow" caption="Reports settings" >}} + +For more information, refer to [Reports settings]({{< relref "../enterprise/reporting.md#reports-settings" >}}). + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading.md" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. + +## What's new in other parts of the Grafana ecosystem + +### ADX (Azure Data Explorer) plugin + +In collaboration with Microsoft, we have improved the usability of our ADX datasource plugin by adding a visual query builder. The goal is to make it easier for users, regardless of their previous knowledge of writing KQL (Kusto Query Language) queries, to query and visualize their data. + +{{< docs-imagebox img="/img/docs/v72/adx-ds.png" max-width="800px" caption="ADX visual query builder" >}} + +### X-Ray data source plugin + +We are pleased to announce our very first version of our data source plugin for AWS X-Ray. You can use this plugin to visualize traces, look at analytics tables, and see insight summaries. For more information, refer to the [X-Ray data source](https://grafana.com/grafana/plugins/grafana-x-ray-datasource) plugin page. diff --git a/docs/sources/whatsnew/whats-new-in-v7-3.md b/docs/sources/whatsnew/whats-new-in-v7-3.md new file mode 100644 index 0000000..3a8d9a3 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-3.md @@ -0,0 +1,161 @@ ++++ +title = "What's New in Grafana v7.3" +description = "Feature and improvement highlights for Grafana v7.3" +keywords = ["grafana", "new", "documentation", "7.3", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-3/"] +weight = -30 +[_build] +list = false ++++ + +# What's new in Grafana v7.3 + +This topic includes the release notes for Grafana v7.3. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) or the [Patch release notes](#patch-release-notes). + +The main highlights are: + +- [**Google Cloud Monitoring:** Out of the box dashboards]({{< relref "#cloud-monitoring-out-of-the-box-dashboards" >}}) +- [**Shorten URL for dashboards and Explore**]({{< relref "#shorten-url-for-dashboards-and-explore" >}}) +- [**Table improvements and new image cell mode**]({{< relref "#table-improvements-and-new-image-cell-mode" >}}) +- [**New color scheme option**]({{< relref "#new-color-scheme-option" >}}) +- [**SigV4 Authentication for Amazon Elasticsearch Service**]({{< relref "#sigv4-authentication-for-aws-users" >}}) +- [**CSV exports for Excel**]({{< relref "#csv-exports-for-excel" >}}) + +## Table improvements and new image cell mode + +The table has been updated with improved hover behavior for cells that have longer content than what fits the current column width. As you can see +in the animated gif below the cell will automatically expand to show you full content of the cell. + +{{< figure src="/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}} + +Another new feature that can be seen in the image above is the new image cell display mode. If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. + +## New color scheme option + +{{< figure src="/img/docs/v73/color_scheme_dropdown.png" max-width="450px" caption="Color scheme" class="pull-right" >}} + +A new standard field [color scheme]({{< relref "../panels/field-options/standard-field-options.md#color-scheme" >}}) option has been added. This new option will provide a unified way for all new panels to specify how colors should be assigned. + +- **Single color**: Specifies a single color. Useful in an override rule. +- **From thresholds**: Informs Grafana to take color from the matching threshold. +- **Classic palette**: Assigns a color by looking up a color in a palette by series index. Useful for Graphs and pie charts, and other categorical data visualizations in Grafana. +- **Green-Yellow-Red (by value)**: A continuous color scheme where Grafana will interpolate a color based on the value assigned to the green, yellow, and red components. The value must be within the min & max limits. +- **Blue-Yellow-Red (by value)**: Same as above but different colors. +- **Blues (by value)**: Same as above but color scheme go from panel background to blue. + +
+ +As you can see this adds new continuous color schemes where Grafana will interpolate colors. A great use of these new color schemes is the table panel where you can color the background and get a heatmap like effect. + +{{< figure src="/img/docs/v73/table_color_scheme.png" max-width="900px" caption="table color scheme" >}} + +Another thing to highlight is that all these new color schemes are theme aware and adapt to the current theme. For example here is how the new monochrome color scheme look like in the light theme: + +{{< figure src="/img/docs/v73/table_color_scheme_mono_light.png" max-width="900px" caption="table color monochrome scheme" >}} + +As this new option is a standard field option it works in every panel. Here is another example from the [Bar Gauge]({{< relref "../panels/visualizations/bar-gauge-panel.md" >}}) panel. + +{{< figure src="/img/docs/v73/bar_gauge_gradient_color_scheme.png" max-width="900px" caption="bar gauge color scheme" >}} + +## CSV exports for Excel + +In v7.0, we introduced a new table panel and inspect mode with Download CSV enabled. However, CSV export to Excel was removed. Due to a large number of inquiries and requests, this [community contribution from tomdaly](https://github.com/grafana/grafana/pull/27284) brought the feature back. + +For more information, refer to [Download raw query results as CSV]({{< relref "../panels/inspect-panel/#download-raw-query-results-as-csv" >}}) in the Grafana documentation. + +## Google Cloud monitoring out-of-the-box dashboards + +The updated Google Cloud monitoring data source is shipped with pre-configured dashboards for five of the most popular Google Cloud Platform (GCP) services: + +- BigQuery +- Cloud Load Balancing +- Cloud SQL +- Google Compute Engine `GCE` +- Google Kubernetes Engine `GKE` + +To import the pre-configured dashboards, go to the configuration page of your Google Cloud Monitoring data source and click on the `Dashboards` tab. Click `Import` for the dashboard you would like to use. To customize the dashboard, we recommend to save the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released. + +For more details, see the [Google Cloud Monitoring docs]({{}}) + +## Shorten URL for dashboards and Explore + +This is an amazing new feature that was created in cooperation with one of our community members. The new share shortened link capability allows you to create smaller and simpler URLs of the format `/goto/:uid` instead of using longer URLs that can contain complex query parameters. In Explore, you can create a shortened link by clicking on the share button in Explore toolbar. In the dashboards, a shortened url option is available through the share panel or dashboard button. + +## SigV4 authentication for AWS users + +You can now configure your Elasticsearch data source to access your Amazon Elasticsearch Service domain directly from Grafana. + +For more details, refer to the [Elasticsearch docs]({{}}). + +## Chaining pipeline aggregation in Elasticsearch + +Thanks to a contribution from a community member, it's now possible to chain multiple pipeline aggregations together and use the results of one pipeline aggregation as the input of another. This unleashes the full power of Elasticsearch's pipeline aggregations in Grafana, allowing users to perform high order derivatives or use a pipeline aggregation result as a variable for a Bucket Script Aggregation. + +## Grafana Enterprise features + +These features are included in the Grafana Enterprise edition software. + +### Auditing + +Auditing tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements. Grafana logs events (as JSON) to file or directly to [loki](/oss/loki/). + +Example of a login event: + +```json +{ + "timestamp": "2020-10-22T10:18:00.838094347Z", + "user": { + "userId": 1, + "orgId": 1, + "isAnonymous": false + }, + "action": "login-grafana", + "result": { + "statusType": "success", + "statusCode": 200 + }, + "requestUri": "/login", + "ipAddress": "127.0.0.1:41324", + "userAgent": "Chrome/86.0.4240.111", + "grafanaVersion": "7.3.0" +} +``` + +For more details, see the [Auditing docs]({{}}). + +### Data source usage insights + +Data source usage insights allows you to gain insight into how a data source is being used and how well it works. There is a new tab in the data source settings page called insights that will show you information about how the data source has been used in the past 30 days. + +Insights: + +- Queries per day +- Errors per day +- Average load duration per day (ms) + +### SAML single logout + +SAML’s single logout (SLO) capability allows users to log out from all applications associated with the current identity provider (IdP) session established via SAML SSO. For more information, refer to the [docs]({{}}). + +### SAML IdP-initiated single sign on + +IdP-initiated single sign on (SSO) allows the user to log in directly from the SAML identity provider (IdP). It is disabled by default for security reasons. For more information, refer to the [docs]({{}}). + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading.md" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. + +## Patch release notes + +- [Grafana 7.3.0 release notes]({{< relref "../release-notes/release-notes-7-3-0.md" >}}) +- [Grafana 7.3.1 release notes]({{< relref "../release-notes/release-notes-7-3-1.md" >}}) +- [Grafana 7.3.2 release notes]({{< relref "../release-notes/release-notes-7-3-2.md" >}}) +- [Grafana 7.3.3 release notes]({{< relref "../release-notes/release-notes-7-3-3.md" >}}) +- [Grafana 7.3.4 release notes]({{< relref "../release-notes/release-notes-7-3-4.md" >}}) +- [Grafana 7.3.5 release notes]({{< relref "../release-notes/release-notes-7-3-5.md" >}}) +- [Grafana 7.3.6 release notes]({{< relref "../release-notes/release-notes-7-3-6.md" >}}) +- [Grafana 7.3.7 release notes]({{< relref "../release-notes/release-notes-7-3-7.md" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v7-4.md b/docs/sources/whatsnew/whats-new-in-v7-4.md new file mode 100644 index 0000000..b0dd1e4 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-4.md @@ -0,0 +1,243 @@ ++++ +title = "What's New in Grafana v7.4" +description = "Feature and improvement highlights for Grafana v7.4" +keywords = ["grafana", "new", "documentation", "7.4", "release notes"] +weight = -31 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-4/"] +[_build] +list = false ++++ + +# What's new in Grafana v7.4 + +This topic includes the release notes for Grafana v7.4. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +Check out the [New Features in 7.4](https://play.grafana.org/d/nP8rcffGk/1-new-features-in-v7-4?orgId=1) dashboard on Grafana Play! + +## Grafana OSS features + +These features are included in the Grafana open source edition. + +### Time series panel visualization (Beta) + +Grafana 7.4 adds a beta version of the next-gen graph visualization. The new graph panel, the _Time series_ visualization, is high-performance visualization based on the uPlot library. This new graph visualization uses the new panel architecture introduced in Grafana 7.0 and integrates with field options, overrides, and transformations. + +The Time series beta panel implements the majority of the functionalities available in the current Graph panel. Our plan is to have close to full coverage of the features in Grafana 8.0, coming later this year. + +Apart from major performance improvements, the new Time series panel implements new features like line interpolation modes, support for more than two Y-axes, soft min and max axis limits, automatic points display based on data density, and gradient fill modes. + +{{< figure src="/img/docs/v74/timeseries_panel.png" max-width="900px" caption="Time series panel" >}} + +The following documentation topics were added for this feature: + +- [Time series panel]({{< relref "../panels/visualizations/time-series/_index.md" >}}) +- [Graph time series as lines]({{< relref "../panels/visualizations/time-series/graph-time-series-as-lines.md" >}}) +- [Graph time series as bars]({{< relref "../panels/visualizations/time-series/graph-time-series-as-bars.md" >}}) +- [Graph time series as points]({{< relref "../panels/visualizations/time-series/graph-time-series-as-points" >}}) +- [Change axis display]({{< relref "../panels/visualizations/time-series/change-axis-display.md" >}}) + +### Node graph panel visualization (Beta) + +_Node graph_ is a new panel type that can visualize directed graphs or network in dashboards, but also in Explore. It uses directed force layout to effectively position the nodes so it can help with displaying complex infrastructure maps, hierarchies, or execution diagrams. + +All the information and stats shown in the Node graph beta are driven by the data provided in the response from the data source. The first data source that is using this panel is AWS X-Ray, for displaying their service map data. + +For more details about how to use the X-Ray service map feature, see the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource). + +For more information, refer to [Node graph panel]({{< relref "../panels/visualizations/node-graph.md" >}}). + +### New transformations + +The following transformations were added in Grafana 7.4. + +#### Sort by transformation + +The _Sort by_ transformation allows you to sort data before sending it to the visualization. + +For more information, refer to [Sort by]({{< relref "../panels/transformations/types-options.md#sort-by" >}}) in [Transformation types and options]({{< relref "../panels/transformations/types-options.md" >}}). + +#### Filter data by value transform + +The new _Filter data by value_ transformation allows you to filter your data directly in Grafana and remove some data points from your query result. + +This transformation is very useful if your data source does not natively filter by values. You might also use this to narrow values to display if you are using a shared query. + +For more information, refer to [Filter data by value]({{< relref "../panels/transformations/types-options.md#filter-data-by-value" >}}) in [Transformation types and options]({{< relref "../panels/transformations/types-options.md" >}}). + +### New override option + +On the Overrides tab, you can now set properties for fields returned by a specific query. + +For more information, refer to [Add a field override]({{< relref "../panels/field-options/configure-specific-fields.md#add-a-field-override" >}}). + +### Exemplar support + +Grafana graphs now support Prometheus _exemplars_. They are displayed as diamonds in the graph visualization. + +> **Note:** Support for exemplars will be added in version Prometheus 2.25+. + +{{< figure src="/img/docs/v74/exemplars.png" max-width="900px" caption="Exemplar example" >}} + +For more information, refer to [Exemplars]({{< relref "../datasources/prometheus.md#exemplars" >}}). + +### Trace to logs + +You can now navigate from a span in a trace view directly to logs relevant for that span. This feature is available for the Tempo, Jaeger, and Zipkin data sources. + +The following topics were updated as a result of this feature: + +- [Explore]({{< relref "../explore/trace-integration.md" >}}) +- [Jaeger]({{< relref "../datasources/jaeger.md#trace-to-logs" >}}) +- [Tempo]({{< relref "../datasources/tempo.md#trace-to-logs" >}}) +- [Zipkin]({{< relref "../datasources/zipkin.md#trace-to-logs" >}}) + +### Server-side expressions + +_Server-side expressions_ is an experimental feature that allows you to manipulate data returned from backend data source queries. Expressions allow you to manipulate data with math and other operations when the data source is a backend data source or a **--Mixed--** data source. + +The main use case is for [multi-dimensional]({{< relref "../basics/timeseries-dimensions.md" >}}) data sources used with the upcoming next generation alerting, but expressions can be used with backend data sources and visualization as well. + +> **Note:** Queries built with this feature might break with minor version upgrades until Grafana 8 is released. This feature does not work with the current Grafana alerting. + +For more information, refer to [Expressions]({{< relref "../panels/expressions.md" >}}). [Queries]({{< relref "../panels/queries.md" >}}) was also updated as a result of this feature. + +### Alert notification query label interpolation + +You can now provide detailed information to alert notification recipients by injecting alert label data as template variables into an alert notification. Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields using the `${Label}` syntax. The alert label data is automatically injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated. + +{{< figure src="/img/docs/alerting/alert-notification-template-7-4.png" max-width="700px" caption="Variable support in alert notifications" >}} + +For more information, refer to the [alert notification docs]({{< relref "../alerting/notifications.md#notification-templating" >}}). + +### Content security policy support + +We have added support for [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), a layer of security that helps detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. + +CSP support is disabled by default, to enable it you must set `content_security_policy = true` in the Grafana configuration. If enabling it, you should also review, and potentially tweak, the CSP header template, via the configuration setting `content_security_policy_template`. + +You can lock down what can be done in the frontend code. Lock down what can be loaded, what JavaScript is executed. Not compatible with some plugins. + +[content_security_policy]({{< relref "../administration/configuration.md#content_security_policy" >}}) and [content_security_policy_template]({{< relref "../administration/configuration.md#content_security_policy_template" >}}) were added to [Configuration]({{< relref "../administration/configuration.md" >}}) as a result of this change. + +### Hide users in UI + +You can now use the `hidden_users` configuration setting to hide specific users in the UI. For example, this feature can be used to hide users that are used for automation purposes. + +[Configuration]({{< relref "../administration/configuration.md#hidden_users" >}}) has been updated for this feature. + +### Elasticsearch data source updates + +Grafana 7.4 includes the following enhancements + +- Added support for serial differencing pipeline aggregation. +- Added support for moving function pipeline aggregation. +- Added support to the terms aggregation for ordering by percentiles and extended stats. +- Updated date histogram auto interval handling for alert queries. + +> **Note:** We have deprecated browser access mode. It will be removed in a future release. + +For more information, refer to the [Elasticsearch docs]({{}}). + +### Azure Monitor updates + +The Azure Monitor query type was renamed to Metrics and Azure Logs Analytics was renamed to Logs to match the service names in Azure and align the concepts with the rest of Grafana. + +[Azure Monitor]({{< relref "../datasources/azuremonitor.md" >}}) was updated to reflect this change. + +### MQL support added for Google Cloud Monitoring + +You can now use Monitoring Query Language (MQL) for querying time-series data. MQL provides an expressive, text-based interface to retrieve, filter, and manipulate time-series data. + +Unlike the visual query builder, MQL allows you to control the time range and period of output data, create new labels to aggregate data, compute the ratio of current values to past values, and so on. + +MQL uses a set of operations and functions. Operations are linked together using the common pipe mechanism, where the output of one operation becomes the input to the next. Linking operations makes it possible to build up complex queries incrementally. + +Once query type Metrics is selected in the Cloud Monitoring query editor, you can toggle between the editor modes for visual query builder and MQL. For more information, refer to the [Google Cloud Monitoring docs]({{< relref "../datasources/google-cloud-monitoring/_index.md#out-of-the-box-dashboards" >}}). + +Many thanks to [mtanda](https://github.com/mtanda) this contribution! + +## Curated dashboards for Google Cloud Monitoring + +Google Cloud Monitoring data source ships with pre-configured dashboards for some of the most popular GCP services. These curated dashboards are based on similar dashboards in the GCP dashboard samples repository. In this release, we have expanded the set of pre-configured dashboards. + +{{< docs-imagebox img="/img/docs/google-cloud-monitoring/curated-dashboards-7-4.png" max-width= "650px" >}} + +If you want to customize a dashboard, we recommend that you save it under a different name. Otherwise the dashboard will be overwritten when a new version of the dashboard is released. + +For more information, refer to the [Google Cloud Monitoring docs]({{< relref "../datasources/google-cloud-monitoring/_index.md/#out-of-the-box-dashboards" >}}). + +### Query Editor Help + +The feature previously referred to as DataSource Start Pages or Cheat Sheets has been renamed to Query Editor Help, and is now supported in panel query editors (depending on the data source), as well as in Explore. + +[Queries]({{< relref "../panels/queries.md" >}}) was updated as a result of this feature. + +For more information on adding a query editor help component to your plugin, refer to [Add a query editor help component]({{< relref "../developers/plugins/add-query-editor-help.md" >}}). + +### Variable inspector + +The variables list has an additional column indicating whether variables are referenced in queries and panel names or not. The dependencies graph provides an easy way to check variable dependencies. You can click on a variable name within the graph to make updates to the variable as needed. + +For more information, refer to [Inspect variables and their dependencies]({{< relref "../variables/inspect-variable.md">}}). + +## Grafana Enterprise features + +These features are included in the Grafana Enterprise edition. + +### Licensing changes + +When determining a user’s role for billing purposes, a user who has the ability to edit and save dashboards is considered an Editor. This includes any user who is an Editor or Admin at the Org level, and who has granted Admin or Edit permissions via [Dashboard and folder permissions]({{< relref "../permissions/dashboard-folder-permissions.md">}}). + +After the number of Viewers or Editors has reached its license limit, only Admins will see a banner in Grafana indicating that the license limit has been reached. Previously, all users saw the banner. + +Grafana Enterprise license tokens update automatically on a daily basis, which means you no longer need to manually update your license, and the process for adding additional users to a license is smoother than it was before. + +Refer to [Licensing restrictions]({{< relref "../enterprise/license/license-restrictions.md" >}}) for more information. + +### Export usage insights to Loki + +You can now export usage insights logs to Loki and query them from Grafana. Usage insights logs include dashboard visits, data source views, queries and errors, and more. + +For more information, refer to [Export logs of usage insights]({{< relref "../enterprise/usage-insights/export-logs.md" >}}). + +### New audit log events + +New log out events are logged based on when a token expires or is revoked, as well as [SAML Single Logout]({{< relref "../enterprise/saml.md#single-logout" >}}). A `tokenId` field was added to all audit logs to help understand which session was logged out of. + +Also, a counter for audit log writing actions with status (success / failure) and logger (loki / file / console) labels was added. + +[Auditing]({{< relref "../enterprise/auditing.md" >}}) was updated to reflect these changes. + +### Reports support Unicode + +You can now select a font, other than the default, for Unicode-based scripts. As a result, an automatically generated PDF of a dashboard, which contains for example Chinese or Cyrillic text, can display them. Because the size of a report increases as additional fonts are added, this feature is not on by default. + +[Reporting]({{< relref "../enterprise/reporting.md#rendering-configuration" >}}) was updated as a result of this change. + +### Request security + +Request security introduces ways to limit requests from the Grafana server, and it targets requests that are generated by users. + +For more information, refer to [Request security]({{< relref "../enterprise/request-security.md" >}}). + +## Breaking changes + +The following Grafana 7.4 changes might break previous functionality. + +### Plugin compatibility + +We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require a small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an $onInit function. For more details on how to migrate AngularJS code open the migration guide and search for pre-assigning bindings. + +In order not to break all angular panel plugins and data sources we have some custom angular inject behavior that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. + +### Fixes Constant variable persistence confusion + +In order to minimize the confusion with Constant variable usage, we've removed the ability to make Constant variables visible. This change will also migrate all existing visible Constant variables to Textbox variables because which we think this is a more appropriate type of variable for this use case. + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading.md" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-5.md b/docs/sources/whatsnew/whats-new-in-v7-5.md new file mode 100644 index 0000000..6ce2acc --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-5.md @@ -0,0 +1,162 @@ ++++ +title = "What's new in Grafana v7.5" +description = "Feature and improvement highlights for Grafana v7.5" +keywords = ["grafana", "new", "documentation", "7.5", "release notes"] +weight = -32 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-5/"] +[_build] +list = false ++++ + +# What’s new in Grafana v7.5 + +This topic includes the release notes for Grafana v7.5. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Grafana OSS features + +These features are included in the Grafana open source edition. + +### Pie chart panel visualization (beta) + +Grafana 7.5 adds a beta version of the next-generation pie chart panel. + +![Pie chart panel](/img/docs/pie-chart-panel/pie-chart-panel-7-5.png) + +For more information, refer to [Pie chart panel]({{< relref "../panels/visualizations/pie-chart-panel.md" >}}). + +### Alerting for Loki + +Grafana 7.5 comes with alerting support for Loki. With LogQL you can wrap a log query with the functions that allow for creating metrics out of the logs, such as "rate()". Metric queries can then be used to calculate things such as the rate of error messages. [When combined with log parsers](https://www.youtube.com/watch?v=H9z2V0Ib1q0), they can be used to calculate metrics from a value within the log line, such latency or request size. + +With alerting support for Loki, you can now create alerts on Loki metrics queries. + +[Alerting]({{< relref "../alerting/_index.md" >}}) was updated as a result of this change. + +![Loki alerting](/img/docs/alerting/alerting-for-loki-7-5.png) + +### Loki label browser + +A new Loki logs browser lets you construct the queries step by step: you choose labels that you like to consider, such as "job", then you select the values that you like, such as "my-app1". Note that you can select values from more than one label, and that they get facetted. This means only possible label combinations are selectable. When you're done, you can run the query either as a logs or a metrics query (a metrics query returns the log volume in a chart). + +### Changed default HTTP method for new Prometheus data sources + +For new Prometheus data sources, we have changed the default HTTP method to POST. POST allows for much larger query bodies than using the GET method. This is necessary when sending queries from graphs with a lot of targets, for example, many hosts in a dashboard variable. The POST method also makes the Query Inspector data easier to read since the query is in plain text whereas the GET query is URL encoded. + +> **Note:** This is not going to affect provisioned data sources or already created data sources. + +[Prometheus data source]({{< relref "../datasources/prometheus.md" >}}) was updated as a result of this change. + +### Word highlighting for Elasticsearch + +When searching for text in Elasticsearch logs, matching words in the log line returned by the query are now highlighted. + +![Elastic logs highlighting](/img/docs/elasticsearch/elastic-word-highlighting-7-5.png) + +### Better format definition for trace data + +In Grafana 7.5, we changed how data for the trace view is sent from the data source. The required data frame has a clear format, which is more aligned with how data is generally represented in Grafana. This makes it easier for third-party developers to implement data sources leveraging the trace view. + +For more information, refer to [trace data API docs]({{< relref "../explore/trace-integration.md#data-api" >}}). + +### Paste in SSL certs for Postgres data source + +Previously, when users wanted to configure the Postgres data source to connect with SSL certification, they needed to put the certification on the server, and configure the data source with file path. + +Instead of the file path, users can now paste the SSL certification content in the UI. This allows them to configure the certification even when they do not have access to the server. + +> **Note:** It remains as limitation for the hosted Grafana, because the user doesn't have access to the server configuration. + +[Postgres data source]({{< relref "../datasources/postgres.md" >}}) and [Provisioning]({{< relref "../administration/provisioning.md" >}}) were updated as a result of this change. + +### Deprecation notice for some Azure Monitor queries + +In the upcoming Grafana 8.0 release, Application Insights and Insights Analytics query types within the Azure Monitor data source will be deprecated and be made read-only in favor of querying Application Insights from Metrics and Logs. + +Grafana 7.5 includes a deprecation notice for these queries, and some documentation to help users prepare for the upcoming changes. + +For more information, refer to [Deprecating Application Insights and Insights Analytics]({{< relref "../datasources/azuremonitor.md#deprecating-application-insights-and-insights-analytics" >}}). + +### Cloudwatch data source enhancements + +- Support for region eu-south-1 has been added to the CloudWatch data source. New metrics have also been added to the namespaces AWS/Timestream, AWS/RDS (RDS Proxy metrics), AWS/NetworkFirewall, AWS/GroundStation, and AWS/DDoSProtection. Many thanks to [relvira](https://github.com/relvira), [ilyastoli](https://github.com/ilyastoli), and [rubycut](https://github.com/rubycut) for contributing! +- Added a page limit to the List Metrics API call to improve speed and reduce memory consumption. You can change this limit by entering a higher value in [list_metrics_page_limit]({{< relref "../administration/configuration.md#list-metrics-page-limit" >}}) in the Grafana configuration file. +- You can now enable or disable authentication providers and assume a role other than default by changing the [allowed_auth_providers]({{< relref "../administration/configuration.md#allowed-auth-providers" >}}) and [assume_role_enabled]({{< relref "../administration/configuration.md#assume-role-enabled" >}}) options in the Grafana configuration file. By default, the allowed authentication providers are _AWS SDK Default_, _Access && secret key_, and _Credentials File_, and role is _Assume role (ARN)_. +- You can now specify a custom endpoint in the CloudWatch data source configuration page. This field is optional, and if it is left empty, then the default endpoint for CloudWatch is used. By specifying a regional endpoint, you can reduce request latency. + + [AWS Cloudwatch data source]({{< relref "../datasources/cloudwatch.md#endpoint" >}}) was updated as a result of this change. + +### Increased API limit for CloudMonitoring Services + +In previous versions, when querying metrics for Service Level Objectives (SLOs) in the CloudMonitoring data source, only the first 100 services were listed in the **Service** field list. To overcome this issue, the API limit for listing services has been increased to 1000. + +### Tempo as a backend data source + +We have converted Tempo to a backend data source and dropped support for tempo-query's (Jaeger) response. To configure it, you can now point to the port that is set in the Tempo configuration file. + +```yaml +server: + http_listen_port: 3101 +``` + +[Azure Monitor data source]({{< relref "../datasources/azuremonitor.md" >}}) was updated as a result of this change. + +## Enterprise features + +These features are included in the Grafana Enterprise edition. + +### Query caching + +When caching is enabled, Grafana temporarily stores the results of data source queries. When you or another user submit the same query again, the results return from the cache instead of from the data source (such as Splunk or ServiceNow). + +Query caching advantages: +- Faster dashboard load times, especially for popular dashboards. +- Reduced API costs. +- Reduced likelihood that APIs will rate-limit or throttle requests. + +Caching currently works for all backend data sources. You can enable the cache globally or per data source, and you can configure the cache duration per data source. The cache is currently in-memory. + +For more information, refer to [Query caching]({{< relref "../enterprise/query-caching.md" >}}). + +### Use template variable in reports + +If you have created dashboards with template variables, then you can choose which values are selected when rendering a report. This makes it easier to tailor reports to their audience or generate multiple reports from the same dashboard. + +Enable this feature in configuration settings using the `templateVariables` flag. + +For more information, refer to [Reporting]({{< relref "../enterprise/reporting.md#choose-template-variables" >}}). + +### Active user limits + +If a Grafana instance has exceeded its licensed number of active users, then non-active users who try to sign in to Grafana will be prevented from doing so. Active users are users who have logged in to Grafana in the past 30 days. The total number of users registered in Grafana does not affect this rule. This enforcement is applied separately for Viewers and for Editor/Admins, so if you reach your active Viewer limit, new Editor/Admins will still be able to sign in. This rule also includes a 10% buffer, meaning that you need to exceed your limit by 10% before users are prevented from signing in. + +Here is an example: + +A Grafana Enterprise instance includes 100 Viewers and 50 Editor/Admins. Over the course of the last 30 days, 110 Viewers and 20 Editor/Admins have signed in to Grafana. + +All of the Viewers who have signed in over the past 30 days will retain the ability to sign in. + +When a previously-inactive Viewer (someone who has not signed in over the past 30 days) tries to sign in, they will see a message and be prevented from signing in until the number of active users dips back below 110. New Editor/Admins are not affected by this; they can continue to sign in until the number of active Editors/Admins reaches 55. + +If you try to sign in to a fourth device or browser, then you will be prevented from doing so; the limit of concurrent sessions is three. + +If you sign in to a fourth device or browser, then you will be signed out of the session that is least current. +Concurrent session limits +Each Grafana Enterprise user will be limited to three concurrent user sessions. When a user opens a fourth session, then the longest-inactive session will be automatically signed out. + +A new session is created when you sign in to Grafana from a different device or a different browser. Multiple windows and tabs in the same browser are all part of the same session, so having many Grafana tabs open will not cause any issues. + +For more information on Grafana Enterprise licensing and restrictions, refer to [License restrictions]({{< relref "../enterprise/license/license-restrictions.md" >}}). + +## Breaking changes + +There are no known breaking changes in this release. + +## Updated configuration + +``` +[server] +read_timeout = 0 +``` + +Sets the maximum time using a duration format (5s/5m/5ms) before timing out read of an incoming request and closing idle connections. +`0` means there is no timeout for reading the request. diff --git a/docs/sources/whatsnew/whats-new-in-v8-0.md b/docs/sources/whatsnew/whats-new-in-v8-0.md new file mode 100644 index 0000000..f017bbb --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v8-0.md @@ -0,0 +1,86 @@ ++++ +title = "What's new in Grafana v8.0" +description = "Feature and improvement highlights for Grafana v8.0" +keywords = ["grafana", "new", "documentation", "8.0", "release notes"] +weight = -33 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v8-0/"] +[_build] +list = false ++++ + +# What’s new in Grafana v8.0 + +> **Note:** This topic will be updated frequently between now and the final release. + +This topic includes the release notes for Grafana v8.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Grafana OSS features + +These features are included in the Grafana open source edition. + +### Library panels + +Library panels allow users to build panels that can be used in multiple dashboards. Any updates made to that shared panel will then automatically be applied to all the dashboards that have that panel. + +### Timeline panel + +Shows discrete status or state transitions of something over time. For example daily uptime or multi-sensor and digital I/O status. + +### Bar chart panel + +New visualization that allows categorical data display. Following the new panel architecture supports field config and overrides, common tooltip, and legend options. + +### Panel editor updates + +- All options are now shown in a single pane. +- You can now search panel options. +- Value mapping has been completely redesigned. + +### Download logs + +You can now download log results as a text (.txt) file. You can access this feature through the Data tab in the Panel inspector and Inspector in Explore. + +### Inspector in Explore + +The new Explore inspector helps you understand and troubleshoot your queries. You can inspect the raw data, export that data to a comma-separated values (CSV) file, export log results in text format, and view query requests. + +### Log improvements + +Logs navigation next to the log lines can be used to request more logs. You can do this by clicking on the Older logs button on the bottom of navigation. This is especially useful when you hit the line limit and you want to see more logs. Each request that is run from the navigation is then displayed in the navigation as a separate page. Every page is showing from and to timestamp of the incoming log lines. You can re-rerun the same request by clicking on the page. + +### Tracing improvements + +- Exemplars +- Better Jaeger search in Explore +- Show trace graph for Jaeger, Zipkin, and Tempo + +### Plugin marketplace + +You can now use the Plugin Marketplace app to easily manage your plugins from within Grafana. Install, update, and uninstall plugins without requiring a server restart. + +## Enterprise features + +These features are included in the Grafana Enterprise edition. + +### Fine-grained access control + +You can now add or remove detailed permissions from Viewer, Editor, and Admin org roles, to grant users just the right amount of access within Grafana. Available permissions include the ability to view and manage Users, Reports, and the Access Control API itself. Grafana will support more and more permissions over the coming months. + +### Data source query caching + +Grafana will now cache the results of backend data source queries, so that multiple users viewing the same dashboard or panel will not each submit the same query to the data source (like Splunk or Snowflake) itself. This results in faster average load times for dashboards and fewer duplicate queries overall to data sources, which reduces cost and the risk of throttling, reaching API limits, or overloading your data sources. Caching can be enabled per-data source, and time-to-live (TTL) can be configured globally and per data source. Query caching can be set up with Redis, Memcached, or a simple in-memory cache. + +### Reporting updates + +When creating a report, you can now choose to export Table Panels as .csv files attached to your report email. This will make it easier for recipients to view and work with that data. You can also link back to the dashboard directly from the email, for users who want to see the data live in Grafana. This release also includes some improvements to the Reports list view. + +## Breaking changes + +The following breaking changes are included in this release. + +### Variables + +- Removed the **Value groups/tags** feature from variables. Any tags will be removed. +- Removed the `never` refresh option for query variables. Existing variables will be migrated and any stored options will be removed. + +Documentation was updated to reflect these changes. diff --git a/e2e/kill-server b/e2e/kill-server new file mode 100755 index 0000000..70bf9f9 --- /dev/null +++ b/e2e/kill-server @@ -0,0 +1,11 @@ +#!/bin/bash + +. e2e/variables + +if [ -f "$PIDFILE" ]; then + echo -e "Found pidfile, killing running grafana-server" + kill -9 `cat $PIDFILE` + rm $PIDFILE +fi + +rm -rf e2e/tmp diff --git a/e2e/run-suite b/e2e/run-suite new file mode 100755 index 0000000..c72e715 --- /dev/null +++ b/e2e/run-suite @@ -0,0 +1,32 @@ +#!/bin/bash +set -xeo pipefail + +. e2e/variables + +HOST=${HOST:-$DEFAULT_HOST} +PORT=${PORT:-$DEFAULT_PORT} + +echo -e "Starting Cypress scenarios" + +CMD="start" +PARAMS="" +SLOWMO=0 +URL=${BASE_URL:-"http://$HOST:$PORT"} +SUITE=${SUITE:-$DEFAULT_SUITE} + +if [ "$1" == "debug" ]; then + echo -e "Debug mode" + SLOWMO=1 + PARAMS="--headed --no-exit" +fi + +if [ "$1" == "dev" ]; then + echo "Dev mode" + CMD="open" +fi + +cd packages/grafana-e2e + +yarn $CMD --env BASE_URL=$URL,SLOWMO=$SLOWMO \ + --config defaultCommandTimeout=30000,integrationFolder=../../e2e/$SUITE/specs,screenshotsFolder=../../e2e/$SUITE/screenshots,videosFolder=../../e2e/$SUITE/videos,fileServerFolder=./cypress,viewportWidth=1920,viewportHeight=1080,trashAssetsBeforeRuns=false \ + $PARAMS diff --git a/e2e/shared/smokeTestScenario.ts b/e2e/shared/smokeTestScenario.ts new file mode 100644 index 0000000..280e67f --- /dev/null +++ b/e2e/shared/smokeTestScenario.ts @@ -0,0 +1,31 @@ +import { e2e } from '@grafana/e2e'; + +export const smokeTestScenario = { + describeName: 'Smoke tests', + itName: 'Login scenario, create test data source, dashboard, panel, and export scenario', + addScenarioDataSource: true, + addScenarioDashBoard: true, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard(); + e2e.components.PageToolbar.item('Add panel').click(); + e2e.pages.AddDashboard.addNewPanel().click(); + + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + }); + + // Make sure the graph renders via checking legend + e2e.components.VizLegend.seriesName('A-series').should('be.visible'); + + // Expand options section + e2e.components.PanelEditor.applyButton(); + + // Make sure panel is & visualization is added to dashboard + e2e.components.VizLegend.seriesName('A-series').should('be.visible'); + }, +}; diff --git a/e2e/start-and-run-suite b/e2e/start-and-run-suite new file mode 100755 index 0000000..9aac995 --- /dev/null +++ b/e2e/start-and-run-suite @@ -0,0 +1,13 @@ +#!/bin/bash + +. e2e/variables + +if [ "$BASE_URL" != "" ]; then + echo -e "BASE_URL set, skipping starting server" +else + # Start it in the background + ./e2e/start-server 2>&1 > e2e/server.log & + ./e2e/wait-for-grafana +fi + +./e2e/run-suite "$@" diff --git a/e2e/start-server b/e2e/start-server new file mode 100755 index 0000000..0590852 --- /dev/null +++ b/e2e/start-server @@ -0,0 +1,60 @@ +#!/bin/bash +set -eo pipefail + +. e2e/variables + +PORT=${PORT:-$DEFAULT_PORT} +PACKAGE_FILE=${PACKAGE_FILE:-$DEFAULT_PACKAGE_FILE} + +./e2e/kill-server + +mkdir $RUNDIR + +echo -e "Copying grafana backend files to temp dir..." + +# Expand any wildcards +pkgs=(${PACKAGE_FILE}) +pkg=${pkgs[0]} +if [[ -f ${pkg} ]]; then + echo "Found package tar file ${pkg}, extracting..." + tar zxf ${pkg} -C $RUNDIR + mv $RUNDIR/grafana-*/* $RUNDIR +else + echo "Couldn't find package ${PACKAGE_FILE} - copying local dev files" + + if [[ ! -f bin/grafana-server ]]; then + echo bin/grafana-server missing + exit 1 + fi + + cp -r ./bin $RUNDIR + cp -r ./public $RUNDIR + cp -r ./tools $RUNDIR + + mkdir $RUNDIR/conf + mkdir $PROV_DIR + mkdir $PROV_DIR/datasources + mkdir $PROV_DIR/dashboards + + cp ./conf/defaults.ini $RUNDIR/conf/defaults.ini +fi + +echo -e "Copy provisioning setup from devenv" + +cp devenv/datasources.yaml $PROV_DIR/datasources +cp devenv/dashboards.yaml $PROV_DIR/dashboards + +cp -r devenv $RUNDIR + +echo -e "Starting Grafana Server port $PORT" + +$RUNDIR/bin/grafana-server \ + --homepath=$RUNDIR \ + --pidfile=$RUNDIR/pid \ + cfg:server.http_port=$PORT \ + cfg:server.router_logging=1 \ + cfg:app_mode=development + +# 2>&1 > $RUNDIR/output.log & +# cfg:log.level=debug \ + diff --git a/e2e/suite1/specs/1-smoketests.spec.ts b/e2e/suite1/specs/1-smoketests.spec.ts new file mode 100644 index 0000000..185eb38 --- /dev/null +++ b/e2e/suite1/specs/1-smoketests.spec.ts @@ -0,0 +1,4 @@ +import { e2e } from '@grafana/e2e'; +import { smokeTestScenario } from '../../shared/smokeTestScenario'; + +e2e.scenario(smokeTestScenario); diff --git a/e2e/suite1/specs/bar-gauge.spec.ts b/e2e/suite1/specs/bar-gauge.spec.ts new file mode 100644 index 0000000..cdc469c --- /dev/null +++ b/e2e/suite1/specs/bar-gauge.spec.ts @@ -0,0 +1,19 @@ +import { e2e } from '@grafana/e2e'; +import { selectors } from '@grafana/e2e-selectors'; + +e2e.scenario({ + describeName: 'Bar Gauge Panel', + itName: 'Bar Gauge rendering e2e tests', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Bar Gauge + e2e.flows.openDashboard({ uid: 'O6f11TZWk' }); + + e2e() + .get(`[data-panelid=6] [aria-label^="${selectors.components.Panels.Visualization.BarGauge.value}"]`) + .should('have.css', 'color', 'rgb(242, 73, 92)') + .contains('100'); + }, +}); diff --git a/e2e/suite1/specs/dashboard-templating.spec.ts b/e2e/suite1/specs/dashboard-templating.spec.ts new file mode 100644 index 0000000..a7c4db0 --- /dev/null +++ b/e2e/suite1/specs/dashboard-templating.spec.ts @@ -0,0 +1,53 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Dashboard templating', + itName: 'Verify variable interpolation works', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // Open dashboard global variables and interpolation + e2e.flows.openDashboard({ uid: 'HYaGDGIMk' }); + + const items: any = []; + const expectedItems: string[] = [ + '__dashboard = Templating - Global variables and interpolation', + '__dashboard.name = Templating - Global variables and interpolation', + '__dashboard.uid = HYaGDGIMk', + '__org.name = Main Org.', + '__org.id = 1', + '__user.id = 1', + '__user.login = admin', + '__user.email = admin@localhost', + `Server:raw = A'A"A,BB\\B,CCC`, + `Server:regex = (A'A"A|BB\\\\B|CCC)`, + `Server:lucene = ("A'A\\"A" OR "BB\\\\B" OR "CCC")`, + `Server:glob = {A'A"A,BB\\B,CCC}`, + `Server:pipe = A'A"A|BB\\B|CCC`, + `Server:distributed = A'A"A,Server=BB\\B,Server=CCC`, + `Server:csv = A'A"A,BB\\B,CCC`, + `Server:html = A'A"A, BB\\B, CCC`, + `Server:json = ["A'A\\"A","BB\\\\B","CCC"]`, + `Server:percentencode = %7BA%27A%22A%2CBB%5CB%2CCCC%7D`, + `Server:singlequote = 'A\\'A"A','BB\\B','CCC'`, + `Server:doublequote = "A'A\\"A","BB\\B","CCC"`, + `Server:sqlstring = 'A''A"A','BB\\\B','CCC'`, + `Server:date = null`, + `Server:text = All`, + `Server:queryparam = var-Server=All`, + ]; + + e2e() + .get('.markdown-html li') + .should('have.length', 24) + .each((element) => { + items.push(element.text()); + }) + .then(() => { + expectedItems.forEach((expected, index) => { + expect(items[index]).to.equal(expected); + }); + }); + }, +}); diff --git a/e2e/suite1/specs/dashboard-time-zone.spec.ts b/e2e/suite1/specs/dashboard-time-zone.spec.ts new file mode 100644 index 0000000..7b4f994 --- /dev/null +++ b/e2e/suite1/specs/dashboard-time-zone.spec.ts @@ -0,0 +1,82 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Dashboard time zone support', + itName: 'Tests dashboard time zone scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCasdf' }); + + const fromTimeZone = 'Coordinated Universal Time'; + const toTimeZone = 'America/Chicago'; + const offset = -5; + + const panelsToCheck = [ + 'Random walk series', + 'Millisecond res x-axis and tooltip', + '2 yaxis and axis labels', + 'Stacking value ontop of nulls', + 'Null between points', + 'Legend Table No Scroll Visible', + ]; + + const timesInUtc: Record = {}; + + for (const title of panelsToCheck) { + e2e.components.Panels.Panel.containerByTitle(title) + .should('be.visible') + .within(() => + e2e.components.Panels.Visualization.Graph.xAxis + .labels() + .should('be.visible') + .last() + .should((element) => { + timesInUtc[title] = element.text(); + }) + ); + } + + e2e.components.PageToolbar.item('Dashboard settings').click(); + + e2e.components.TimeZonePicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('be.visible').should('have.text', fromTimeZone); + + e2e.components.Select.input().should('be.visible').click(); + + e2e.components.Select.option().should('be.visible').contains(toTimeZone).click(); + }); + + e2e.components.BackButton.backArrow().click(); + + for (const title of panelsToCheck) { + e2e.components.Panels.Panel.containerByTitle(title) + .should('be.visible') + .within(() => + e2e.components.Panels.Visualization.Graph.xAxis + .labels() + .should('be.visible') + .last() + .should((element) => { + const utc = timesInUtc[title]; + const tz = element.text(); + const isCorrect = isTimeCorrect(utc, tz, offset); + assert.isTrue(isCorrect, `Panel with title: "${title}"`); + }) + ); + } + }, +}); + +const isTimeCorrect = (utc: string, tz: string, offset: number): boolean => { + const minutes = 1000 * 60; + + const a = Cypress.moment(utc, 'HH:mm').set('seconds', 0).set('milliseconds', 0); + + const b = Cypress.moment(tz, 'HH:mm').set('seconds', 0).set('milliseconds', 0).add('hours', offset); + + return a.diff(b, 'minutes') <= 6 * minutes; +}; diff --git a/e2e/suite1/specs/explore.spec.ts b/e2e/suite1/specs/explore.spec.ts new file mode 100644 index 0000000..f472f04 --- /dev/null +++ b/e2e/suite1/specs/explore.spec.ts @@ -0,0 +1,25 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Explore', + itName: 'Basic path through Explore.', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.pages.Explore.visit(); + e2e.pages.Explore.General.container().should('have.length', 1); + e2e.components.RefreshPicker.runButton().should('have.length', 1); + + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + }); + + const canvases = e2e().get('canvas'); + canvases.should('have.length', 1); + }, +}); diff --git a/e2e/suite1/specs/gauge.spec.ts b/e2e/suite1/specs/gauge.spec.ts new file mode 100644 index 0000000..4fb1552 --- /dev/null +++ b/e2e/suite1/specs/gauge.spec.ts @@ -0,0 +1,21 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Gauge Panel', + itName: 'Gauge rendering e2e tests', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Gauge + e2e.flows.openDashboard({ uid: '_5rDmaQiz' }); + + cy.wait(1000); + + // check that gauges are rendered + e2e().get('body').find(`.flot-base`).should('have.length', 16); + + // check that no panel errors exist + e2e.components.Panels.Panel.headerCornerInfo('error').should('not.exist'); + }, +}); diff --git a/e2e/suite1/specs/inspect-drawer.spec.ts b/e2e/suite1/specs/inspect-drawer.spec.ts new file mode 100644 index 0000000..51087db --- /dev/null +++ b/e2e/suite1/specs/inspect-drawer.spec.ts @@ -0,0 +1,148 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = '2 yaxis and axis labels'; + +e2e.scenario({ + describeName: 'Inspect drawer tests', + itName: 'Tests various Inspect Drawer scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // @ts-ignore some typing issue + e2e().on('uncaught:exception', (err) => { + if (err.stack?.indexOf("TypeError: Cannot read property 'getText' of null") !== -1) { + // On occasion monaco editor will not have the time to be properly unloaded when we change the tab + // and then the e2e test fails with the uncaught:exception: + // TypeError: Cannot read property 'getText' of null + // at Object.ai [as getFoldingRanges] (http://localhost:3001/public/build/monaco-json.worker.js:2:215257) + // at e.getFoldingRanges (http://localhost:3001/public/build/monaco-json.worker.js:2:221188) + // at e.fmr (http://localhost:3001/public/build/monaco-json.worker.js:2:116605) + // at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7414) + // at Object.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7018) + // at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:5038) + // at e.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:4606) + // at e.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7097) + // at Tt.self.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:117109) + + // return false to prevent the error from + // failing this test + return false; + } + + return true; + }); + + const viewPortWidth = e2e.config().viewportWidth; + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + // testing opening inspect drawer directly by clicking on Inspect in header menu + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST); + + expectDrawerTabsAndContent(); + + expectDrawerExpandAndContract(viewPortWidth); + + expectDrawerClose(); + + expectSubMenuScenario('Data'); + expectSubMenuScenario('Query'); + expectSubMenuScenario('Panel JSON', 'JSON'); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + e2e.components.QueryTab.queryInspectorButton().should('be.visible').click(); + + e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`) + .should('be.visible') + .within(() => { + e2e.components.Tab.title('Query').should('be.visible'); + // query should be the active tab + e2e.components.Tab.active().should('have.text', 'Query'); + }); + + e2e.components.PanelInspector.Query.content().should('be.visible'); + }, +}); + +const expectDrawerTabsAndContent = () => { + e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`) + .should('be.visible') + .within(() => { + e2e.components.Tab.title('Data').should('be.visible'); + // data should be the active tab + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Data'); + }); + e2e.components.PanelInspector.Data.content().should('be.visible'); + e2e.components.PanelInspector.Stats.content().should('not.exist'); + e2e.components.PanelInspector.Json.content().should('not.exist'); + e2e.components.PanelInspector.Query.content().should('not.exist'); + + // other tabs should also be visible, click on each to see if we get any console errors + e2e.components.Tab.title('Stats').should('be.visible').click(); + e2e.components.PanelInspector.Stats.content().should('be.visible'); + e2e.components.PanelInspector.Data.content().should('not.exist'); + e2e.components.PanelInspector.Json.content().should('not.exist'); + e2e.components.PanelInspector.Query.content().should('not.exist'); + + e2e.components.Tab.title('JSON').should('be.visible').click(); + e2e.components.PanelInspector.Json.content().should('be.visible'); + e2e.components.PanelInspector.Data.content().should('not.exist'); + e2e.components.PanelInspector.Stats.content().should('not.exist'); + e2e.components.PanelInspector.Query.content().should('not.exist'); + + e2e.components.Tab.title('Query').should('be.visible').click(); + + e2e.components.PanelInspector.Query.content().should('be.visible'); + e2e.components.PanelInspector.Data.content().should('not.exist'); + e2e.components.PanelInspector.Stats.content().should('not.exist'); + e2e.components.PanelInspector.Json.content().should('not.exist'); + }); +}; + +const expectDrawerClose = () => { + // close using close button + e2e.components.Drawer.General.close().click(); + e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`).should('not.exist'); +}; + +const expectDrawerExpandAndContract = (viewPortWidth: number) => { + // try expand button + // drawer should take up half the screen + e2e.components.Drawer.General.rcContentWrapper() + .should('be.visible') + .should('have.css', 'width', `${viewPortWidth / 2}px`); + + e2e.components.Drawer.General.expand().click(); + e2e.components.Drawer.General.contract().should('be.visible'); + + // drawer should take up the whole screen + e2e.components.Drawer.General.rcContentWrapper() + .should('be.visible') + .should('have.css', 'width', `${viewPortWidth}px`); + + // try contract button + e2e.components.Drawer.General.contract().click(); + e2e.components.Drawer.General.expand().should('be.visible'); + + e2e.components.Drawer.General.rcContentWrapper() + .should('be.visible') + .should('have.css', 'width', `${viewPortWidth / 2}px`); +}; + +const expectSubMenuScenario = (subMenu: string, tabTitle?: string) => { + tabTitle = tabTitle ?? subMenu; + // testing opening inspect drawer from sub menus under Inspect in header menu + e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible').click(); + + // sub menus are in the DOM but not visible and because there is no hover support in Cypress force click + // https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/testing-dom__hover-hidden-elements/cypress/integration/hover-hidden-elements-spec.js + e2e.components.Panels.Panel.headerItems(subMenu).click({ force: true }); + + // data should be the default tab + e2e.components.Tab.title(tabTitle).should('be.visible'); + e2e.components.Tab.active().should('have.text', tabTitle); + + expectDrawerClose(); +}; diff --git a/e2e/suite1/specs/panelEdit_base.spec.ts b/e2e/suite1/specs/panelEdit_base.spec.ts new file mode 100644 index 0000000..2f6f551 --- /dev/null +++ b/e2e/suite1/specs/panelEdit_base.spec.ts @@ -0,0 +1,99 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = 'Lines 500 data points'; + +e2e.scenario({ + describeName: 'Panel edit tests', + itName: 'Tests various Panel edit scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: 'TkZXxlNG3' }); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + // New panel editor opens when navigating from Panel menu + e2e.components.PanelEditor.General.content().should('be.visible'); + + // Queries tab is rendered and open by default + e2e.components.PanelEditor.DataPane.content() + .should('be.visible') + .within(() => { + e2e.components.Tab.title('Query').should('be.visible'); + // data should be the active tab + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Query1'); // there's already a query so therefore Query + 1 + }); + e2e.components.QueryTab.content().should('be.visible'); + e2e.components.TransformTab.content().should('not.exist'); + e2e.components.AlertTab.content().should('not.exist'); + + // Bottom pane tabs + // Can change to Transform tab + e2e.components.Tab.title('Transform').should('be.visible').click(); + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Transform0'); // there's no transform so therefore Transform + 0 + }); + e2e.components.Transforms.card('Merge').scrollIntoView().should('be.visible'); + e2e.components.QueryTab.content().should('not.exist'); + e2e.components.AlertTab.content().should('not.exist'); + + // Can change to Alerts tab (graph panel is the default vis so the alerts tab should be rendered) + e2e.components.Tab.title('Alert').should('be.visible').click(); + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Alert0'); // there's no alert so therefore Alert + 0 + }); + e2e.components.AlertTab.content().should('be.visible'); + e2e.components.QueryTab.content().should('not.exist'); + e2e.components.TransformTab.content().should('not.exist'); + + e2e.components.Tab.title('Query').should('be.visible').click(); + }); + + // Panel sidebar is rendered open by default + e2e.components.PanelEditor.OptionsPane.content().should('be.visible'); + + // close options pane + e2e.components.PanelEditor.toggleVizOptions().click(); + e2e.components.PanelEditor.OptionsPane.content().should('not.exist'); + + e2e().wait(100); + + // open options pane + e2e.components.PanelEditor.toggleVizOptions().should('be.visible').click(); + e2e.components.PanelEditor.OptionsPane.content().should('be.visible'); + + // Check that Time series is chosen + e2e.components.PanelEditor.toggleVizPicker().click(); + e2e.components.PluginVisualization.item('Time series').should('be.visible'); + e2e.components.PluginVisualization.current().should((e) => expect(e).to.contain('Time series')); + + // Check that table view works + e2e.components.PanelEditor.toggleTableView().click({ force: true }); + e2e.components.Panels.Visualization.Table.header() + .should('be.visible') + .within(() => { + cy.contains('A-series').should('be.visible'); + }); + + // Change to Text panel + e2e.components.PluginVisualization.item('Text').scrollIntoView().should('be.visible').click(); + e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Text')); + + // Data pane should not be rendered + e2e.components.PanelEditor.DataPane.content().should('not.exist'); + + // Change to Table panel + e2e.components.PanelEditor.toggleVizPicker().click(); + e2e.components.PluginVisualization.item('Table').scrollIntoView().should('be.visible').click(); + e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Table')); + + // Data pane should be rendered + e2e.components.PanelEditor.DataPane.content().should('be.visible'); + + // Field & Overrides tabs (need to switch to React based vis, i.e. Table) + e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Show header').should('be.visible'); + e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Column width').should('be.visible'); + }, +}); diff --git a/e2e/suite1/specs/panelEdit_queries.spec.ts b/e2e/suite1/specs/panelEdit_queries.spec.ts new file mode 100644 index 0000000..db20e9e --- /dev/null +++ b/e2e/suite1/specs/panelEdit_queries.spec.ts @@ -0,0 +1,104 @@ +import { e2e } from '@grafana/e2e'; +import { expect } from '../../../public/test/lib/common'; + +const PANEL_UNDER_TEST = 'Random walk series'; +const flakyTimeout = 10000; + +e2e.scenario({ + describeName: 'Panel edit tests - queries', + itName: 'Testes various Panel edit queries scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + // New panel editor opens when navigating from Panel menu + e2e.components.PanelEditor.General.content().should('be.visible'); + + // Queries tab is rendered and open by default + e2e.components.PanelEditor.DataPane.content().should('be.visible'); + + // We expect row with refId A to exist and be visible + e2e.components.QueryEditorRows.rows().within((rows) => { + expect(rows.length).equals(1); + }); + + // Add query button should be visible and clicking on it should create a new row + e2e.components.QueryTab.addQuery().scrollIntoView().should('be.visible').click(); + + // We expect row with refId A and B to exist and be visible + e2e.components.QueryEditorRows.rows({ timeout: flakyTimeout }).should('have.length', 2); + + // Remove refId A + e2e.components.QueryEditorRow.actionButton('Remove query').eq(0).should('be.visible').click(); + + // We expect row with refId B to exist and be visible + e2e.components.QueryEditorRows.rows({ timeout: flakyTimeout }).should('have.length', 1); + + // Duplicate refId B + e2e.components.QueryEditorRow.actionButton('Duplicate query').eq(0).should('be.visible').click(); + + // We expect row with refId Band and A to exist and be visible + e2e.components.QueryEditorRows.rows().within((rows) => { + expect(rows.length).equals(2); + }); + + // Change to CSV Metric Values scenario for A + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + e2e.components.Select.input().eq(0).should('be.visible').click(); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').eq(0).click(); + }); + + // Disable / enable row + expectInspectorResultAndClose((keys) => { + const length = keys.length; + const resultIds = new Set([ + keys[length - 2].innerText, // last 2 + keys[length - 1].innerText, // last 2 + ]); + + expect(resultIds.has('A:')).equals(true); + expect(resultIds.has('B:')).equals(true); + }); + + // Disable row with refId A + e2e.components.QueryEditorRow.actionButton('Disable/enable query').eq(1).should('be.visible').click(); + + expectInspectorResultAndClose((keys) => { + const length = keys.length; + expect(keys[length - 1].innerText).equals('B:'); + }); + + // Enable row with refId B + e2e.components.QueryEditorRow.actionButton('Disable/enable query').eq(1).should('be.visible').click(); + + expectInspectorResultAndClose((keys) => { + const length = keys.length; + const resultIds = new Set([ + keys[length - 2].innerText, // last 2 + keys[length - 1].innerText, // last 2 + ]); + + expect(resultIds.has('A:')).equals(true); + expect(resultIds.has('B:')).equals(true); + }); + }, +}); + +const expectInspectorResultAndClose = (expectCallBack: (keys: any[]) => void) => { + e2e.components.QueryTab.queryInspectorButton().should('be.visible').click(); + + e2e.components.PanelInspector.Query.refreshButton().should('be.visible').click(); + + e2e.components.PanelInspector.Query.jsonObjectKeys({ timeout: flakyTimeout }) + .should('be.visible') + .within((keys: any) => expectCallBack(keys)); + + e2e.components.Drawer.General.close().should('be.visible').click(); +}; diff --git a/e2e/suite1/specs/panelEdit_transforms.spec.ts b/e2e/suite1/specs/panelEdit_transforms.spec.ts new file mode 100644 index 0000000..49e551c --- /dev/null +++ b/e2e/suite1/specs/panelEdit_transforms.spec.ts @@ -0,0 +1,22 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = 'Random walk series'; + +e2e.scenario({ + describeName: 'Panel edit tests - transformations', + itName: 'Tests transformations editor', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + e2e.components.Tab.title('Transform').should('be.visible').click(); + + e2e.components.TransformTab.newTransform('Reduce').should('be.visible').click(); + + e2e.components.Transforms.Reduce.calculationsLabel().should('be.visible'); + }, +}); diff --git a/e2e/suite1/specs/pie-chart.spec.ts b/e2e/suite1/specs/pie-chart.spec.ts new file mode 100644 index 0000000..1d3339e --- /dev/null +++ b/e2e/suite1/specs/pie-chart.spec.ts @@ -0,0 +1,18 @@ +import { e2e } from '@grafana/e2e'; +import { selectors } from '@grafana/e2e-selectors'; + +e2e.scenario({ + describeName: 'Pie Chart Panel', + itName: 'Pie Chart rendering e2e tests', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Pie Chart + e2e.flows.openDashboard({ uid: 'lVE-2YFMz' }); + + e2e() + .get(`[data-panelid=11] [aria-label^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]`) + .should('have.length', 5); + }, +}); diff --git a/e2e/suite1/specs/query-editor.spec.ts b/e2e/suite1/specs/query-editor.spec.ts new file mode 100644 index 0000000..edc15ba --- /dev/null +++ b/e2e/suite1/specs/query-editor.spec.ts @@ -0,0 +1,28 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Query editor', + itName: 'Undo should work in query editor for prometheus.', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.pages.Explore.visit(); + e2e.components.DataSourcePicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + cy.contains('gdev-prometheus').scrollIntoView().should('be.visible').click(); + }); + const queryText = 'http_requests_total'; + + e2e.components.QueryField.container().should('be.visible').type(queryText).type('{backspace}'); + + cy.contains(queryText.slice(0, -1)).should('be.visible'); + + e2e.components.QueryField.container().type(e2e.typings.undo()); + + cy.contains(queryText).should('be.visible'); + }, +}); diff --git a/e2e/suite1/specs/select-focus.spec.ts b/e2e/suite1/specs/select-focus.spec.ts new file mode 100644 index 0000000..0087e3a --- /dev/null +++ b/e2e/suite1/specs/select-focus.spec.ts @@ -0,0 +1,31 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Select focus/unfocus tests', + itName: 'Tests select focus/unfocus scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + e2e.components.PageToolbar.item('Dashboard settings').click(); + + e2e.components.FolderPicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + e2e.components.Select.option().should('be.visible').first().click(); + + e2e.components.Select.input().should('exist').should('have.focus'); + }); + + e2e.pages.Dashboard.Settings.General.title().click(); + + e2e.components.FolderPicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('exist').should('not.have.focus'); + }); + }, +}); diff --git a/e2e/suite1/specs/solo-route.spec.ts b/e2e/suite1/specs/solo-route.spec.ts new file mode 100644 index 0000000..52c74ef --- /dev/null +++ b/e2e/suite1/specs/solo-route.spec.ts @@ -0,0 +1,15 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Solo Route', + itName: 'Can view panels with shared queries in fullsceen', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Bar Gauge + e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); + + e2e().get('canvas').should('have.length', 6); + }, +}); diff --git a/e2e/suite1/specs/templating-dashboard-links-and-variables.ts b/e2e/suite1/specs/templating-dashboard-links-and-variables.ts new file mode 100644 index 0000000..926f63f --- /dev/null +++ b/e2e/suite1/specs/templating-dashboard-links-and-variables.ts @@ -0,0 +1,60 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Templating', + itName: 'Tests dashboard links and variables in links', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: '/api/search?tag=templating&limit=100', + }) + .as('tagsTemplatingSearch'); + e2e() + .route({ + method: 'GET', + url: '/api/search?tag=demo&limit=100', + }) + .as('tagsDemoSearch'); + + // waiting for links to render, couldn't find a better way using routes for instance + e2e().wait(1000); + + const verifyLinks = (variableValue: string) => { + e2e.components.DashboardLinks.link() + .should('be.visible') + .and((links) => { + expect(links).to.have.length.greaterThan(13); + + for (let index = 0; index < links.length; index++) { + expect(Cypress.$(links[index]).attr('href')).contains(`var-custom=${variableValue}`); + } + }); + }; + + e2e.components.DashboardLinks.dropDown() + .should('be.visible') + .click() + .wait('@tagsTemplatingSearch') + .wait('@tagsDemoSearch'); + + // verify all links, should have All value + verifyLinks('All'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch'); + + // verify all links, should have p2 value + verifyLinks('p2'); + }, +}); diff --git a/e2e/suite1/specs/trace-view-scrolling.spec.ts b/e2e/suite1/specs/trace-view-scrolling.spec.ts new file mode 100644 index 0000000..4f0f7f5 --- /dev/null +++ b/e2e/suite1/specs/trace-view-scrolling.spec.ts @@ -0,0 +1,35 @@ +import { e2e } from '@grafana/e2e'; + +describe('Trace view', () => { + it('Can lazy load big traces', () => { + e2e.flows.login('admin', 'admin'); + e2e() + .intercept('GET', '/api/traces/long-trace', { + fixture: 'long-trace-response.json', + }) + .as('longTrace'); + + e2e.pages.Explore.visit(); + + e2e.components.DataSourcePicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + e2e().contains('gdev-jaeger').scrollIntoView().should('be.visible').click(); + }); + + e2e.components.DataSource.Jaeger.traceIDInput().should('be.visible').type('long-trace'); + + e2e.components.RefreshPicker.runButton().should('be.visible').click(); + + e2e().wait('@longTrace'); + + e2e.components.TraceViewer.spanBar().should('have.length', 100); + + e2e.pages.Explore.General.scrollBar().scrollTo('center'); + + // After scrolling we should have 140 spans instead of the first 100 + e2e.components.TraceViewer.spanBar().should('have.length', 140); + }); +}); diff --git a/e2e/suite1/specs/variables/load-options-from-url.ts b/e2e/suite1/specs/variables/load-options-from-url.ts new file mode 100644 index 0000000..6253346 --- /dev/null +++ b/e2e/suite1/specs/variables/load-options-from-url.ts @@ -0,0 +1,156 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; + +describe('Variables - Load options from Url', () => { + it('default options should be correct', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAC').should('be.visible'); + }); + + it('options set in url should load correct options', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=B&var-server=BB&var-pod=BBB` }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BBB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); + }); + + it('options set in url that do not exist should load correct options', () => { + e2e.flows.login('admin', 'admin'); + // @ts-ignore some typing issue + e2e().on('uncaught:exception', (err) => { + if (err.stack?.indexOf("Couldn't find any field of type string in the results.") !== -1) { + // return false to prevent the error from + // failing this test + return false; + } + + return true; + }); + + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=X` }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('X').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 10); + }); + }); +}); diff --git a/e2e/suite1/specs/variables/new-query-variable.ts b/e2e/suite1/specs/variables/new-query-variable.ts new file mode 100644 index 0000000..d214b22 --- /dev/null +++ b/e2e/suite1/specs/variables/new-query-variable.ts @@ -0,0 +1,193 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; + +describe('Variables - Add variable', () => { + it('query variable should be default and default fields should be correct', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput() + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('name'); + expect(input.val()).equals('query0'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('be.visible').should('have.text', 'Query'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput() + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('optional display name'); + expect(input.val()).equals(''); + }); + e2e() + .get('#Description') + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('descriptive text'); + expect(input.val()).equals(''); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalHideSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', ''); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'gdev-testdata'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'On dashboard load'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput() + .should('be.visible') + .within((input) => { + const placeholder = '/.*-(?.*)-(?.*)-.*/'; + expect(input.attr('placeholder')).equals(placeholder); + expect(input.val()).equals(''); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'Disabled'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch().should('not.be.checked'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch().should('not.be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('not.exist'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().should('not.exist'); + }); + + it('adding a single value query variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput() + .should('be.visible') + .clear() + .type('a label'); + + e2e().get('#Description').should('be.visible').clear().type('a description'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type('gdev-testdata').type('{enter}'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() + .should('be.visible') + .type('*') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput() + .should('be.visible') + .type('/.*C.*/') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().should('be.visible').click(); + + e2e().wait(1500); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('have.length', 4) + .eq(3) + .within(() => { + e2e().get('.variable-link-wrapper').should('be.visible').click(); + e2e().wait(500); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 1); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + }); + }); + + it('adding a multi value query variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput() + .should('be.visible') + .clear() + .type('a label'); + + e2e().get('#Description').should('be.visible').clear().type('a description'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type('gdev-testdata').type('{enter}'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() + .should('be.visible') + .type('*') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput() + .should('be.visible') + .type('/.*C.*/') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch() + .click({ force: true }) + .should('be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch() + .click({ force: true }) + .should('be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().within((input) => { + expect(input.attr('placeholder')).equals('blank = auto'); + expect(input.val()).equals(''); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().should('be.visible').click(); + + e2e().wait(500); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('have.length', 4) + .eq(3) + .within(() => { + e2e().get('.variable-link-wrapper').should('be.visible').click(); + e2e().wait(500); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 2); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + }); + }); +}); diff --git a/e2e/suite1/specs/variables/set-options-from-ui.ts b/e2e/suite1/specs/variables/set-options-from-ui.ts new file mode 100644 index 0000000..9449ce8 --- /dev/null +++ b/e2e/suite1/specs/variables/set-options-from-ui.ts @@ -0,0 +1,155 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; +const flakyTimeout = 5000; + +describe('Variables - Set options from ui', () => { + it('clicking a value that is not part of dependents options should change these to All', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-server=AA&var-pod=AAA` }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible'); + + e2e().wait(flakyTimeout); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All') + .should('have.length', 2) + .eq(0) + .should('be.visible') + .click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e().wait(flakyTimeout); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 10); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCC').should('be.visible'); + }); + + it('adding a value that is not part of dependents options should add the new values dependant options', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-server=AA&var-pod=AAA` }); + e2e().intercept('/api/ds/query').as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e().wait('@query'); + e2e().wait(500); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').scrollIntoView().should('be.visible'); + + e2e.components.LoadingIndicator.icon().should('have.length', 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 7); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AAA').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAC').should('be.visible'); + }); + + it('removing a value that is part of dependents options should remove the new values dependant options', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ + uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-datacenter=B&var-server=AA&var-server=BB&var-pod=AAA&var-pod=BBB`, + }); + e2e().intercept('/api/ds/query').as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e().wait('@query'); + e2e().wait(500); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible'); + + e2e.components.LoadingIndicator.icon().should('have.length', 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BBB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); + }); +}); diff --git a/e2e/suite1/specs/variables/textbox-variables.ts b/e2e/suite1/specs/variables/textbox-variables.ts new file mode 100644 index 0000000..0f02ea4 --- /dev/null +++ b/e2e/suite1/specs/variables/textbox-variables.ts @@ -0,0 +1,247 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = 'AejrN1AMz'; + +describe('TextBox - load options scenarios', function () { + it('default options should be correct', function () { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1` }); + + validateTextboxAndMarkup('default value'); + }); + + it('loading variable from url should be correct', function () { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ + uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1&var-text=not default value`, + }); + + validateTextboxAndMarkup('not default value'); + }); +}); + +describe.skip('TextBox - change query scenarios', function () { + it('when changing the query value and not saving current as default should revert query value', function () { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + + validateVariable('changed value'); + }); + }); + + it('when changing the query value and saving current as default should change query value', function () { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + + validateVariable('changed value'); + }); + }); +}); + +describe.skip('TextBox - change picker value scenarios', function () { + it('when changing the input value and not saving current as default should revert query value', function () { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + validateVariable('default value'); + }); + }); + + it('when changing the input value and saving current as default should change query value', function () { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + validateVariable('changed value'); + }); + }); +}); + +function copyExistingDashboard() { + e2e.flows.login('admin', 'admin'); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: '/api/search?query=&type=dash-folder&permission=Edit', + }) + .as('dash-settings'); + e2e() + .route({ + method: 'POST', + url: '/api/dashboards/db/', + }) + .as('save-dash'); + e2e() + .route({ + method: 'GET', + url: /\/api\/dashboards\/uid\/(?!AejrN1AMz)\w+/, + }) + .as('load-dash'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1&editview=settings` }); + + e2e().wait('@dash-settings'); + + e2e.pages.Dashboard.Settings.General.saveAsDashBoard().should('be.visible').click(); + + e2e.pages.SaveDashboardAsModal.newName().should('be.visible').type(`${Date.now()}`); + + e2e.pages.SaveDashboardAsModal.save().should('be.visible').click(); + + e2e().wait('@save-dash'); + e2e().wait('@load-dash'); + + e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible'); + + e2e() + .location() + .then((loc) => { + const dashuid = /\/d\/(\w+)\//.exec(loc.href)![1]; + e2e().wrap(dashuid).as('dashuid'); + }); + + e2e().wait(500); +} + +function saveDashboard(saveVariables: boolean) { + e2e.components.PageToolbar.item('Save dashboard').should('be.visible').click(); + + if (saveVariables) { + e2e.pages.SaveDashboardModal.saveVariables().should('exist').click({ force: true }); + } + + e2e.pages.SaveDashboardModal.save().should('be.visible').click(); + + e2e().wait('@save-dash'); +} + +function validateTextboxAndMarkup(value: string) { + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e().get('input').should('be.visible').should('have.value', value); + }); + + e2e.components.Panels.Visualization.Text.container() + .should('be.visible') + .within(() => { + e2e().get('h1').should('be.visible').should('have.text', `variable: ${value}`); + }); +} + +function validateVariable(value: string) { + e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput() + .should('be.visible') + .should('have.value', value); +} + +function changeTextBoxInput() { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e() + .get('input') + .should('be.visible') + .should('have.value', 'default value') + .clear() + .type('changed value') + .type('{enter}'); + }); + + e2e() + .location() + .should((loc) => { + expect(loc.search).to.contain('var-text=changed%20value'); + }); +} + +function changeQueryInput() { + e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput() + .should('be.visible') + .clear() + .type('changed value') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('have.length', 1) + .should('have.text', 'changed value'); +} diff --git a/e2e/suite1/tsconfig.json b/e2e/suite1/tsconfig.json new file mode 100644 index 0000000..f1cce18 --- /dev/null +++ b/e2e/suite1/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["cypress"] + }, + "extends": "../../tsconfig.json", + "include": ["**/*.ts", "../../packages/grafana-e2e/cypress/support/index.d.ts"] +} diff --git a/e2e/variables b/e2e/variables new file mode 100644 index 0000000..8a76f61 --- /dev/null +++ b/e2e/variables @@ -0,0 +1,10 @@ +#!/bin/bash + +DEFAULT_RUNDIR=e2e/tmp +RUNDIR=${RUNDIR:-$DEFAULT_RUNDIR} +PIDFILE=$RUNDIR/pid +DEFAULT_PACKAGE_FILE=dist/grafana-*linux-amd64.tar.gz +PROV_DIR=$RUNDIR/conf/provisioning +DEFAULT_HOST=localhost +DEFAULT_PORT=3001 +DEFAULT_SUITE=suite1 diff --git a/e2e/verify-release b/e2e/verify-release new file mode 100755 index 0000000..fb923d5 --- /dev/null +++ b/e2e/verify-release @@ -0,0 +1,5 @@ +#!/bin/bash + +. e2e/variables + +SUITE=verify ./e2e/run-suite diff --git a/e2e/verify/specs/smoketests.spec.ts b/e2e/verify/specs/smoketests.spec.ts new file mode 100644 index 0000000..185eb38 --- /dev/null +++ b/e2e/verify/specs/smoketests.spec.ts @@ -0,0 +1,4 @@ +import { e2e } from '@grafana/e2e'; +import { smokeTestScenario } from '../../shared/smokeTestScenario'; + +e2e.scenario(smokeTestScenario); diff --git a/e2e/verify/tsconfig.json b/e2e/verify/tsconfig.json new file mode 100644 index 0000000..f1cce18 --- /dev/null +++ b/e2e/verify/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["cypress"] + }, + "extends": "../../tsconfig.json", + "include": ["**/*.ts", "../../packages/grafana-e2e/cypress/support/index.d.ts"] +} diff --git a/e2e/wait-for-grafana b/e2e/wait-for-grafana new file mode 100755 index 0000000..b3c9839 --- /dev/null +++ b/e2e/wait-for-grafana @@ -0,0 +1,11 @@ +#!/bin/bash +set -eo pipefail + +. e2e/variables + +HOST=${HOST:-$DEFAULT_HOST} +PORT=${PORT:-$DEFAULT_PORT} + +echo -e "Waiting for grafana-server to finish starting, host=$HOST, port=$PORT" + +timeout 60 bash -c 'until nc -z $0 $1; do sleep 1; done' $HOST $PORT diff --git a/emails/README.md b/emails/README.md new file mode 100644 index 0000000..562d524 --- /dev/null +++ b/emails/README.md @@ -0,0 +1,15 @@ +## Prerequisites + +- npm install +- gem install premailer + +## Tasks + +- npm run build (default task will build new inlines email templates) +- npm start (will build on source html or css change) + +## Result + +Assembled email templates will be in `dist/` and final +inlined templates will be in `../public/emails/` + diff --git a/emails/assets/css/ink.css b/emails/assets/css/ink.css new file mode 100644 index 0000000..f4c1a29 --- /dev/null +++ b/emails/assets/css/ink.css @@ -0,0 +1,688 @@ +/********************************************** +* Ink v1.0.5 - Copyright 2013 ZURB Inc * +**********************************************/ + +/* Client-specific Styles & Reset */ + +#outlook a { + padding:0; +} + +body{ + width:100% !important; + min-width: 100%; + -webkit-text-size-adjust:100%; + -ms-text-size-adjust:100%; + margin:0; + padding:0; +} + + + +.ExternalClass { + width:100%; +} + +.ExternalClass, +.ExternalClass p, +.ExternalClass span, +.ExternalClass font, +.ExternalClass td, +.ExternalClass div { + line-height: 100%; +} + +#backgroundTable { + margin:0; + padding:0; + width:100% !important; + line-height: 100% !important; +} + +img { + outline:none; + text-decoration:none; + -ms-interpolation-mode: bicubic; + width: auto; + float: left; + clear: both; + display: block; +} + +center { + width: 100%; + min-width: 580px; +} + +a img { + border: none; +} + +p { + margin: 0 0 0 10px; +} + +table { + border-spacing: 0; + border-collapse: collapse; +} + +td { + word-break: break-word; + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; + border-collapse: collapse !important; +} + +table, tr, td { + padding: 0; + vertical-align: top; + text-align: left; +} + +hr { + color: #d9d9d9; + background-color: #d9d9d9; + height: 1px; + border: none; +} + +/* Responsive Grid */ + +table.body { + height: 100%; + width: 100%; +} + +table.container { + width: 580px; + margin: 0 auto; + text-align: inherit; +} + +table.row { + padding: 0px; + width: 100%; + position: relative; +} + +table.container table.row { + display: block; +} + +td.wrapper { + padding: 10px 20px 0px 0px; + position: relative; +} + +table.columns, +table.column { + margin: 0 auto; +} + +table.columns td, +table.column td { + padding: 0px 0px 10px; +} + +table.columns td.sub-columns, +table.column td.sub-columns, +table.columns td.sub-column, +table.column td.sub-column { + padding-right: 10px; +} + +td.sub-column, td.sub-columns { + min-width: 0px; +} + +table.row td.last, +table.container td.last { + padding-right: 0px; +} + +table.one { width: 30px; } +table.two { width: 80px; } +table.three { width: 130px; } +table.four { width: 180px; } +table.five { width: 230px; } +table.six { width: 280px; } +table.seven { width: 330px; } +table.eight { width: 380px; } +table.nine { width: 430px; } +table.ten { width: 480px; } +table.eleven { width: 530px; } +table.twelve { width: 580px; } + +table.one center { min-width: 30px; } +table.two center { min-width: 80px; } +table.three center { min-width: 130px; } +table.four center { min-width: 180px; } +table.five center { min-width: 230px; } +table.six center { min-width: 280px; } +table.seven center { min-width: 330px; } +table.eight center { min-width: 380px; } +table.nine center { min-width: 430px; } +table.ten center { min-width: 480px; } +table.eleven center { min-width: 530px; } +table.twelve center { min-width: 580px; } + +table.one .panel center { min-width: 10px; } +table.two .panel center { min-width: 60px; } +table.three .panel center { min-width: 110px; } +table.four .panel center { min-width: 160px; } +table.five .panel center { min-width: 210px; } +table.six .panel center { min-width: 260px; } +table.seven .panel center { min-width: 310px; } +table.eight .panel center { min-width: 360px; } +table.nine .panel center { min-width: 410px; } +table.ten .panel center { min-width: 460px; } +table.eleven .panel center { min-width: 510px; } +table.twelve .panel center { min-width: 560px; } + +.body .columns td.one, +.body .column td.one { width: 8.333333%; } +.body .columns td.two, +.body .column td.two { width: 16.666666%; } +.body .columns td.three, +.body .column td.three { width: 25%; } +.body .columns td.four, +.body .column td.four { width: 33.333333%; } +.body .columns td.five, +.body .column td.five { width: 41.666666%; } +.body .columns td.six, +.body .column td.six { width: 50%; } +.body .columns td.seven, +.body .column td.seven { width: 58.333333%; } +.body .columns td.eight, +.body .column td.eight { width: 66.666666%; } +.body .columns td.nine, +.body .column td.nine { width: 75%; } +.body .columns td.ten, +.body .column td.ten { width: 83.333333%; } +.body .columns td.eleven, +.body .column td.eleven { width: 91.666666%; } +.body .columns td.twelve, +.body .column td.twelve { width: 100%; } + +td.offset-by-one { padding-left: 50px; } +td.offset-by-two { padding-left: 100px; } +td.offset-by-three { padding-left: 150px; } +td.offset-by-four { padding-left: 200px; } +td.offset-by-five { padding-left: 250px; } +td.offset-by-six { padding-left: 300px; } +td.offset-by-seven { padding-left: 350px; } +td.offset-by-eight { padding-left: 400px; } +td.offset-by-nine { padding-left: 450px; } +td.offset-by-ten { padding-left: 500px; } +td.offset-by-eleven { padding-left: 550px; } + +td.expander { + visibility: hidden; + width: 0px; + padding: 0 !important; +} + +table.columns .text-pad, +table.column .text-pad { + padding-left: 10px; + padding-right: 10px; +} + +table.columns .left-text-pad, +table.columns .text-pad-left, +table.column .left-text-pad, +table.column .text-pad-left { + padding-left: 10px; +} + +table.columns .right-text-pad, +table.columns .text-pad-right, +table.column .right-text-pad, +table.column .text-pad-right { + padding-right: 10px; +} + +/* Block Grid */ + +.block-grid { + width: 100%; + max-width: 580px; +} + +.block-grid td { + display: inline-block; + padding:10px; +} + +.two-up td { + width:270px; +} + +.three-up td { + width:173px; +} + +.four-up td { + width:125px; +} + +.five-up td { + width:96px; +} + +.six-up td { + width:76px; +} + +.seven-up td { + width:62px; +} + +.eight-up td { + width:52px; +} + +/* Alignment & Visibility Classes */ + +table.center, td.center { + text-align: center; +} + +h1.center, +h2.center, +h3.center, +h4.center, +h5.center, +h6.center { + text-align: center; +} + +span.center { + display: block; + width: 100%; + text-align: center; +} + +img.center { + margin: 0 auto; + float: none; +} + +.show-for-small, +.hide-for-desktop { + display: none; +} + +/* Typography */ + +body, table.body, h1, h2, h3, h4, h5, h6, p, td { + color: #222222; + font-family: "Helvetica", "Arial", sans-serif; + font-weight: normal; + padding:0; + margin: 0; + text-align: left; + line-height: 1.3; +} + +h1, h2, h3, h4, h5, h6 { + word-break: normal; +} + +h1 {font-size: 40px;} +h2 {font-size: 36px;} +h3 {font-size: 32px;} +h4 {font-size: 28px;} +h5 {font-size: 24px;} +h6 {font-size: 20px;} +body, table.body, p, td {font-size: 14px;line-height:19px;} + +p.lead, p.lede, p.leed { + font-size: 18px; + line-height:21px; +} + +p { + margin-bottom: 10px; +} + +small { + font-size: 10px; +} + +a { + color: #2ba6cb; + text-decoration: none; +} + +a:hover { + color: #2795b6 !important; +} + +a:active { + color: #2795b6 !important; +} + +a:visited { + color: #2ba6cb !important; +} + +h1 a, +h2 a, +h3 a, +h4 a, +h5 a, +h6 a { + color: #2ba6cb; +} + +h1 a:active, +h2 a:active, +h3 a:active, +h4 a:active, +h5 a:active, +h6 a:active { + color: #2ba6cb !important; +} + +h1 a:visited, +h2 a:visited, +h3 a:visited, +h4 a:visited, +h5 a:visited, +h6 a:visited { + color: #2ba6cb !important; +} + +/* Panels */ + +.panel { + background: #f2f2f2; + border: 1px solid #d9d9d9; + padding: 10px !important; +} + +.sub-grid table { + width: 100%; +} + +.sub-grid td.sub-columns { + padding-bottom: 0; +} + +/* Buttons */ + +table.button, +table.tiny-button, +table.small-button, +table.medium-button, +table.large-button { + width: 100%; + overflow: hidden; +} + +table.button td, +table.tiny-button td, +table.small-button td, +table.medium-button td, +table.large-button td { + display: block; + width: auto !important; + text-align: center; + background: #2ba6cb; + border: 1px solid #2284a1; + color: #ffffff; + padding: 8px 0; +} + +table.tiny-button td { + padding: 5px 0 4px; +} + +table.small-button td { + padding: 8px 0 7px; +} + +table.medium-button td { + padding: 12px 0 10px; +} + +table.large-button td { + padding: 21px 0 18px; +} + +table.button td a, +table.tiny-button td a, +table.small-button td a, +table.medium-button td a, +table.large-button td a { + font-weight: bold; + text-decoration: none; + font-family: Helvetica, Arial, sans-serif; + color: #ffffff; + font-size: 16px; +} + +table.tiny-button td a { + font-size: 12px; + font-weight: normal; +} + +table.small-button td a { + font-size: 16px; +} + +table.medium-button td a { + font-size: 20px; +} + +table.large-button td a { + font-size: 24px; +} + +table.button:hover td, +table.button:visited td, +table.button:active td { + background: #2795b6 !important; +} + +table.button:hover td a, +table.button:visited td a, +table.button:active td a { + color: #fff !important; +} + +table.button:hover td, +table.tiny-button:hover td, +table.small-button:hover td, +table.medium-button:hover td, +table.large-button:hover td { + background: #2795b6 !important; +} + +table.button:hover td a, +table.button:active td a, +table.button td a:visited, +table.tiny-button:hover td a, +table.tiny-button:active td a, +table.tiny-button td a:visited, +table.small-button:hover td a, +table.small-button:active td a, +table.small-button td a:visited, +table.medium-button:hover td a, +table.medium-button:active td a, +table.medium-button td a:visited, +table.large-button:hover td a, +table.large-button:active td a, +table.large-button td a:visited { + color: #ffffff !important; +} + +table.secondary td { + background: #e9e9e9; + border-color: #d0d0d0; + color: #555; +} + +table.secondary td a { + color: #555; +} + +table.secondary:hover td { + background: #d0d0d0 !important; + color: #555; +} + +table.secondary:hover td a, +table.secondary td a:visited, +table.secondary:active td a { + color: #555 !important; +} + +table.success td { + background: #5da423; + border-color: #457a1a; +} + +table.success:hover td { + background: #457a1a !important; +} + +table.alert td { + background: #c60f13; + border-color: #970b0e; +} + +table.alert:hover td { + background: #970b0e !important; +} + +table.radius td { + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} + +table.round td { + -webkit-border-radius: 500px; + -moz-border-radius: 500px; + border-radius: 500px; +} + +/* Outlook First */ + +body.outlook p { + display: inline !important; +} + +/* Media Queries */ + +@media only screen and (max-width: 600px) { + + table[class="body"] img { + + } + + table[class="body"] center { + min-width: 0 !important; + } + + table[class="body"] .container { + width: 95% !important; + } + + table[class="body"] .row { + width: 100% !important; + display: block !important; + } + + table[class="body"] .wrapper { + display: block !important; + padding-right: 0 !important; + } + + table[class="body"] .columns, + table[class="body"] .column { + table-layout: fixed !important; + float: none !important; + width: 100% !important; + padding-right: 0px !important; + padding-left: 0px !important; + display: block !important; + } + + table[class="body"] .wrapper.first .columns, + table[class="body"] .wrapper.first .column { + display: table !important; + } + + table[class="body"] table.columns td, + table[class="body"] table.column td { + width: 100% !important; + } + + table[class="body"] .columns td.one, + table[class="body"] .column td.one { width: 8.333333% !important; } + table[class="body"] .columns td.two, + table[class="body"] .column td.two { width: 16.666666% !important; } + table[class="body"] .columns td.three, + table[class="body"] .column td.three { width: 25% !important; } + table[class="body"] .columns td.four, + table[class="body"] .column td.four { width: 33.333333% !important; } + table[class="body"] .columns td.five, + table[class="body"] .column td.five { width: 41.666666% !important; } + table[class="body"] .columns td.six, + table[class="body"] .column td.six { width: 50% !important; } + table[class="body"] .columns td.seven, + table[class="body"] .column td.seven { width: 58.333333% !important; } + table[class="body"] .columns td.eight, + table[class="body"] .column td.eight { width: 66.666666% !important; } + table[class="body"] .columns td.nine, + table[class="body"] .column td.nine { width: 75% !important; } + table[class="body"] .columns td.ten, + table[class="body"] .column td.ten { width: 83.333333% !important; } + table[class="body"] .columns td.eleven, + table[class="body"] .column td.eleven { width: 91.666666% !important; } + table[class="body"] .columns td.twelve, + table[class="body"] .column td.twelve { width: 100% !important; } + + table[class="body"] td.offset-by-one, + table[class="body"] td.offset-by-two, + table[class="body"] td.offset-by-three, + table[class="body"] td.offset-by-four, + table[class="body"] td.offset-by-five, + table[class="body"] td.offset-by-six, + table[class="body"] td.offset-by-seven, + table[class="body"] td.offset-by-eight, + table[class="body"] td.offset-by-nine, + table[class="body"] td.offset-by-ten, + table[class="body"] td.offset-by-eleven { + padding-left: 0 !important; + } + + table[class="body"] table.columns td.expander { + width: 1px !important; + } + + table[class="body"] .right-text-pad, + table[class="body"] .text-pad-right { + padding-left: 10px !important; + } + + table[class="body"] .left-text-pad, + table[class="body"] .text-pad-left { + padding-right: 10px !important; + } + + table[class="body"] .hide-for-small, + table[class="body"] .show-for-desktop { + display: none !important; + } + + table[class="body"] .show-for-small, + table[class="body"] .hide-for-desktop { + display: inherit !important; + } +} diff --git a/emails/assets/css/style.css b/emails/assets/css/style.css new file mode 100644 index 0000000..83f8d2c --- /dev/null +++ b/emails/assets/css/style.css @@ -0,0 +1,195 @@ + +body, table.body, h1, h2, h3, h4, h5, h6, p, td { + font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: none; +} + +h1 {font-size: 40px;} +h2 {font-size: 36px;} +h3 { + font-size: 22px; + margin-top: 10px; + margin-bottom: 10px; +} +h4 {font-size: 20px;} +h5 {font-size: 18px;} +h6 {font-size: 16px;} + +.emphasis { + font-weight: 600; +} + +a { + color: #E67612; + text-decoration: none; +} + +a:hover { + color: #ff8f2b !important; +} + +a:active { + color: #F2821E !important; +} + +a:visited { + color: #E67612 !important; +} + +table.facebook td { + background: #3b5998; + border-color: #2d4473; +} + +table.facebook:hover td { + background: #2d4473 !important; +} + +table.twitter td { + background: #00acee; + border-color: #0087bb; +} + +table.twitter:hover td { + background: #0087bb !important; +} + +table.google-plus td { + background-color: #DB4A39; + border-color: #CC0000; +} + +table.google-plus:hover td { + background: #CC0000 !important; +} + +.template-label { + color: #ffffff; + font-weight: bold; + font-size: 11px; +} + +.callout .wrapper { + padding-bottom: 20px; +} + +.callout .panel { + background: #ECF8FF; + border-color: #b9e5ff; +} + +.header { +margin-top:25px; +margin-bottom: 25px; +} + +.data { + font-size: 16px; +} + +.footer { + background-color: #2e2e2e; + color: #999999; + margin-top: 20px; +} + +@media only screen and (max-width: 600px) { + table[class="body"] .right-text-pad { + padding-left: 10px !important; + } + + table[class="body"] .left-text-pad { + padding-right: 10px !important; + } + + .logo { + margin-left: 10px; + } +} + +table.better-button { + margin-top: 10px; + margin-bottom: 20px; +} + +table.columns td.better-button { + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + padding-bottom: 0px; +} + +.better-button a { + text-decoration: none; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + + padding: 12px 25px; + border: 1px solid #ff8f2b; + display: inline-block; + color: #FFF; +} + +.better-button:hover a { + color: #FFFFFF !important; + background-color: #F2821E; + border: 1px solid #F2821E; +} + +.better-button:visited a { + color: #FFFFFF !important; +} + +.better-button:active a { + color: #FFFFFF !important; +} + +table.better-button-alt { + margin-top: 10px; + margin-bottom: 20px; +} + +table.columns td.better-button-alt { + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + padding-bottom: 0px; +} + +.better-button-alt a { + text-decoration: none; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + + padding: 12px 25px; + border: 1px solid #ff8f2b; + background-color: #EFEFEF; + display: inline-block; + color: #ff8f2b; +} + +.better-button-alt:hover a { + color: #ff8f2b !important; + background-color: #DDDDDD; + border: 1px solid #F2821E; +} + +.better-button-alt:visited a { + color: #ff8f2b !important; +} + +.better-button-alt:active a { + color: #ff8f2b !important; +} + +.verification-code { + background-color: #EEEEEE; + padding: 3px; + margin: 8px; + display: inline-block; + font-weight: bold; + font-size: 20px; +} diff --git a/emails/grunt/aliases.yaml b/emails/grunt/aliases.yaml new file mode 100644 index 0000000..6a2e477 --- /dev/null +++ b/emails/grunt/aliases.yaml @@ -0,0 +1,8 @@ + +default: + - 'clean' + - 'assemble' + - 'replace' + - 'uncss' + - 'processhtml' + - 'premailer' diff --git a/emails/grunt/assemble.js b/emails/grunt/assemble.js new file mode 100644 index 0000000..40686af --- /dev/null +++ b/emails/grunt/assemble.js @@ -0,0 +1,16 @@ +module.exports = function () { + 'use strict'; + return { + options: { + layout: 'templates/layouts/default.html', + partials: ['templates/partials/*.hbs'], + helpers: ['templates/helpers/**/*.js'], + data: [], + flatten: true, + }, + pages: { + src: ['templates/*.html'], + dest: 'dist/', + }, + }; +}; diff --git a/emails/grunt/clean.js b/emails/grunt/clean.js new file mode 100644 index 0000000..a829e29 --- /dev/null +++ b/emails/grunt/clean.js @@ -0,0 +1,5 @@ +module.exports = function (config) { + return { + dist: ['dist'], + }; +}; diff --git a/emails/grunt/premailer.js b/emails/grunt/premailer.js new file mode 100644 index 0000000..34671a7 --- /dev/null +++ b/emails/grunt/premailer.js @@ -0,0 +1,16 @@ +module.exports = { + main: { + options: { + verbose: true, + removeComments: true, + }, + files: [ + { + expand: true, // Enable dynamic expansion. + cwd: 'dist', // Src matches are relative to this path. + src: ['*.html'], // Actual pattern(s) to match. + dest: '../public/emails/', // Destination path prefix. + }, + ], + }, +}; diff --git a/emails/grunt/processhtml.js b/emails/grunt/processhtml.js new file mode 100644 index 0000000..777b2d2 --- /dev/null +++ b/emails/grunt/processhtml.js @@ -0,0 +1,12 @@ +module.exports = { + dist: { + files: [ + { + expand: true, // Enable dynamic expansion. + cwd: 'dist', // Src matches are relative to this path. + src: ['*.html'], // Actual pattern(s) to match. + dest: 'dist/', // Destination path prefix. + }, + ], + }, +}; diff --git a/emails/grunt/replace.js b/emails/grunt/replace.js new file mode 100644 index 0000000..dff1639 --- /dev/null +++ b/emails/grunt/replace.js @@ -0,0 +1,16 @@ +module.exports = { + dist: { + overwrite: true, + src: ['dist/*.html'], + replacements: [ + { + from: '[[', + to: '{{', + }, + { + from: ']]', + to: '}}', + }, + ], + }, +}; diff --git a/emails/grunt/uncss.js b/emails/grunt/uncss.js new file mode 100644 index 0000000..c1ec535 --- /dev/null +++ b/emails/grunt/uncss.js @@ -0,0 +1,9 @@ +module.exports = { + dist: { + src: ['dist/*.html'], + dest: 'dist/css/tidy.css', + options: { + report: 'min', // optional: include to report savings + }, + }, +}; diff --git a/emails/grunt/watch.js b/emails/grunt/watch.js new file mode 100644 index 0000000..8596d0b --- /dev/null +++ b/emails/grunt/watch.js @@ -0,0 +1,15 @@ +module.exports = { + src: { + files: [ + //what are the files that we want to watch + 'assets/css/*.css', + 'templates/**/*.html', + 'grunt/*.js', + ], + tasks: ['default'], + options: { + nospawn: true, + livereload: false, + }, + }, +}; diff --git a/emails/gruntfile.js b/emails/gruntfile.js new file mode 100644 index 0000000..1864eab --- /dev/null +++ b/emails/gruntfile.js @@ -0,0 +1,4 @@ +module.exports = function (grunt) { + // load grunt config + require('load-grunt-config')(grunt); +}; diff --git a/emails/package.json b/emails/package.json new file mode 100644 index 0000000..f4eb62d --- /dev/null +++ b/emails/package.json @@ -0,0 +1,26 @@ +{ + "name": "Grafana-Email-Campaign", + "version": "1.0.0", + "description": "Grafana Email templates based on Zurb Ink", + "repository": "dnnsldr/", + "author": { + "name": "dnnsldr", + "email": "delder@riester.com", + "url": "https://github.com/dnnsldr" + }, + "scripts": { + "build": "grunt", + "start": "grunt watch" + }, + "devDependencies": { + "grunt": "1.0.1", + "grunt-premailer": "1.1.0", + "grunt-processhtml": "^0.4.2", + "grunt-uncss": "0.9.0", + "load-grunt-config": "3.0.1", + "grunt-contrib-watch": "1.1.0", + "grunt-text-replace": "0.4.0", + "grunt-assemble": "0.6.3", + "grunt-contrib-clean": "2.0.0" + } +} diff --git a/emails/templates/alert_notification.html b/emails/templates/alert_notification.html new file mode 100644 index 0000000..d41104e --- /dev/null +++ b/emails/templates/alert_notification.html @@ -0,0 +1,134 @@ +[[Subject .Subject "[[.Title]]"]] + + + + + +
+ + + + +
+

[[.Title]]

+
+
+ + + + + +
+ + + + +
+

[[.Message]]

+
+
+ +[[if ne .Error "" ]] + + + + +
+
+ + + + + + + +
+
Error message
+
+

[[.Error]]

+
+
+
+[[end]] + +[[if ne .State "ok" ]] + + + + +
+
+ + + + + + [[range .EvalMatches]] + + + + + [[end]] +
+
Metric name
+
+
Value
+
+
[[.Metric]]
+
+
[[.Value]]
+
+
+
+[[end]] + + + + + +
+ + + + +
+ [[if ne .ImageLink "" ]] + Alerting Panel + [[end]] + [[if ne .EmbeddedImage "" ]] + Alerting Panel + [[end]] +
+
+ + + + + + +
+ + + + + +
+ + + + +
+ View your Alert rule +
+
+ + + + +
+ Go to the Alerts page +
+
+
+ + diff --git a/emails/templates/invited_to_org.html b/emails/templates/invited_to_org.html new file mode 100644 index 0000000..6818306 --- /dev/null +++ b/emails/templates/invited_to_org.html @@ -0,0 +1,47 @@ + + +[[Subject .Subject "[[.InvitedBy]] has added you to the [[.OrgName]] organization"]] + + + + + +
+ + + + + + +
+

You have been added to [[.OrgName]]

+
+ +
+ + + + + +
+ + + + + + + + +
+

[[.InvitedBy]] has added you to the [[.OrgName]] organization in Grafana. +

Once logged in, [[.OrgName]] will be available in the left side menu, in the dropdown below your username.

+
+ + + + +
Log in now
+
+
+ + diff --git a/emails/templates/layouts/default.html b/emails/templates/layouts/default.html new file mode 100644 index 0000000..80708ea --- /dev/null +++ b/emails/templates/layouts/default.html @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + +
+ + + + + + +
+ +
+ +
+ +
+
+ + + + + + + + +
+ {{> body }} + +
+ + + + + + +
+
+ + diff --git a/emails/templates/new_user_invite.html b/emails/templates/new_user_invite.html new file mode 100644 index 0000000..6e75a20 --- /dev/null +++ b/emails/templates/new_user_invite.html @@ -0,0 +1,49 @@ + + +[[Subject .Subject "[[.InvitedBy]] has invited you to join Grafana"]] + + + + + +
+ + + + + + +
+

You're invited to join [[.OrgName]]

+
+ +
+ + + + + +
+ + + + + + + + + + + +
+

You've been invited to join the [[.OrgName]] organization by [[.InvitedBy]]. To accept your invitation and join the team, please click the link below:

+
+ + + + +
Accept Invitation
+
+

You can also copy/paste this link into your browser directly: [[.LinkUrl]]

+
+
\ No newline at end of file diff --git a/emails/templates/ng_alert_notification.html b/emails/templates/ng_alert_notification.html new file mode 100644 index 0000000..7eb4496 --- /dev/null +++ b/emails/templates/ng_alert_notification.html @@ -0,0 +1,150 @@ +[[Subject .Subject "[[.Title]]"]] + + + + + + + +
+ + + + +
+ [[ if gt (len .Alerts.Firing) 0 ]] +

[[.Title]]

+ [[ else ]] +

[[.Title]]

+ [[ end ]] +
+
+ + + + [[ if gt (len .Alerts.Firing) 0 ]] + + [[ else ]] + + [[ end ]] + + + + + +
+ [[ .Alerts | len ]] alert[[ if gt (len .Alerts) 1 ]]s[[ end ]] for + [[ range .GroupLabels.SortedPairs ]] + [[ .Name ]]=[[ .Value ]] + [[ end ]] + + [[ .Alerts | len ]] alert[[ if gt (len .Alerts) 1 ]]s[[ end ]] for + [[ range .GroupLabels.SortedPairs ]] + [[ .Name ]]=[[ .Value ]] + [[ end ]] +
+ + [[ if gt (len .Alerts.Firing) 0 ]] + + + + [[ end ]] + [[ range .Alerts.Firing ]] + + + + + + + + + [[ end ]] + + [[ if gt (len .Alerts.Resolved) 0 ]] + [[ if gt (len .Alerts.Firing) 0 ]] + + + + [[ end ]] + + + + [[ end ]] + [[ range .Alerts.Resolved ]] + + + + + + + + + [[ end ]] +
+
([[ .Alerts.Firing | len ]]) Firing
+
+
Labels
+
+ [[ if gt (len .Annotations) 0 ]]
Annotations
[[ end ]] +
+ [[ range .Labels.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] + Source
+
+ [[ range .Annotations.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] +
+
+
+
+
+
([[ .Alerts.Resolved | len ]]) Resolved
+
+
Labels
+
+ [[ if gt (len .Annotations) 0 ]]
Annotations
[[ end ]] +
+ [[ range .Labels.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] + Source
+
+ [[ range .Annotations.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] +
+
+ + + + + + + +
+ + + + + +
+ + + + +
+ View your Alert rule +
+
+ + + + +
+ Go to the Alerts page +
+
+
+ + diff --git a/emails/templates/reset_password.html b/emails/templates/reset_password.html new file mode 100644 index 0000000..e9d1527 --- /dev/null +++ b/emails/templates/reset_password.html @@ -0,0 +1,42 @@ +[[Subject .Subject "Reset your Grafana password - [[.Name]]"]] + + + + + +
+ + + + + + +
+

Hi [[.Name]],

+
+ +
+ + + + + +
+ + + + + +
+

+ Please click the following link to reset your password within [[.EmailCodeValidHours]] hours. +

+

+ [[.AppUrl]]user/password/reset?code=[[.Code]] +

+

Not working? Try copying and pasting it to your browser.

+
+ +
+ + diff --git a/emails/templates/signup_started.html b/emails/templates/signup_started.html new file mode 100644 index 0000000..80b4c32 --- /dev/null +++ b/emails/templates/signup_started.html @@ -0,0 +1,46 @@ +[[Subject .Subject "Welcome to Grafana, please complete your sign up!"]] + + + + + +
+ + + + + + +
+

Complete the signup

+
+ +
+ + + + + +
+ + + + + + + + +
+ Copy and paste the email verification code:
+ [[.Code]]
in + the sign up form or use the link below. +
+ + + + +
Complete Sign Up
+
+
+ + diff --git a/emails/templates/welcome_on_signup.html b/emails/templates/welcome_on_signup.html new file mode 100644 index 0000000..c75b68f --- /dev/null +++ b/emails/templates/welcome_on_signup.html @@ -0,0 +1,48 @@ +[[Subject .Subject "Welcome to Grafana"]] + + + + + +
+ + + + + + + + + +
+

Hi [[.Name]],

+
+ Welcome! Ready to start building some beautiful metric and analytic dashboards? +
+ +
+ + + + + +
+ + + + + + + + +
+

+ If you are new to Grafana please read the Getting Started guide. +

+
+ Thank you for joining our community. +
+

The Grafana Team

+
+
+ diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..0f4ba09 --- /dev/null +++ b/embed.go @@ -0,0 +1,21 @@ +package grafana + +import ( + "embed" + "io/fs" +) + +// CoreSchema embeds all CUE files within the cue/ subdirectory. +// +// TODO good rule about where to search +// +//go:embed cue/*/*.cue +var CoreSchema embed.FS + +// TODO good rule about where to search +// +//go:embed public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json +var base embed.FS + +// PluginSchema embeds all CUE files within the public/ subdirectory. +var PluginSchema, _ = fs.Sub(base, "public/app/plugins") diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..af105fb --- /dev/null +++ b/go.mod @@ -0,0 +1,114 @@ +module github.com/grafana/grafana + +go 1.16 + +// Override xorm's outdated go-mssqldb dependency, since we can't upgrade to current xorm (due to breaking changes). +// We need a more current go-mssqldb so we get rid of a version of apache/thrift with vulnerabilities. +// Also, use our fork with fixes for unimplemented methods (required for Go 1.16). +replace github.com/denisenkom/go-mssqldb => github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 + +// Override k8s.io/client-go outdated dependency, which is an indirect dependency of grafana/loki. +// It's also present on grafana/loki's go.mod so we'll need till it gets updated. +replace k8s.io/client-go => k8s.io/client-go v0.18.8 + +require ( + cloud.google.com/go/storage v1.14.0 + cuelang.org/go v0.3.2 + github.com/BurntSushi/toml v0.3.1 + github.com/Masterminds/semver v1.5.0 + github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f + github.com/aws/aws-sdk-go v1.38.34 + github.com/beevik/etree v1.1.0 + github.com/benbjohnson/clock v1.1.0 + github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b + github.com/centrifugal/centrifuge v0.17.0 + github.com/cortexproject/cortex v1.8.2-0.20210428155238-d382e1d80eaf + github.com/crewjam/saml v0.4.6-0.20201227203850-bca570abb2ce + github.com/davecgh/go-spew v1.1.1 + github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204 + github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect + github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b + github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect + github.com/facebookgo/structtag v0.0.0-20150214074306-217e25fb9691 // indirect + github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect + github.com/fatih/color v1.10.0 + github.com/gchaincl/sqlhooks v1.3.0 + github.com/getsentry/sentry-go v0.10.0 + github.com/go-kit/kit v0.10.0 + github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b + github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 + github.com/go-openapi/strfmt v0.20.1 + github.com/go-sourcemap/sourcemap v2.1.3+incompatible + github.com/go-sql-driver/mysql v1.6.0 + github.com/go-stack/stack v1.8.0 + github.com/gobwas/glob v0.2.3 + github.com/golang/mock v1.5.0 + github.com/google/go-cmp v0.5.5 + github.com/google/uuid v1.2.0 + github.com/gorilla/websocket v1.4.2 + github.com/gosimple/slug v1.9.0 + github.com/grafana/grafana-aws-sdk v0.4.0 + github.com/grafana/grafana-live-sdk v0.0.6-0.20210513051437-bf97d7ff8f21 + github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 + github.com/grafana/grafana-plugin-sdk-go v0.97.0 + github.com/grafana/loki v1.6.2-0.20210510132741-f408e05ad426 + github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 + github.com/hashicorp/go-hclog v0.16.0 + github.com/hashicorp/go-plugin v1.4.0 + github.com/hashicorp/go-version v1.3.0 + github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec + github.com/influxdata/influxdb-client-go/v2 v2.2.3 + github.com/jmespath/go-jmespath v0.4.0 + github.com/json-iterator/go v1.1.11 + github.com/jung-kurt/gofpdf v1.16.2 + github.com/lib/pq v1.10.0 + github.com/linkedin/goavro/v2 v2.10.0 + github.com/magefile/mage v1.11.0 + github.com/mattn/go-isatty v0.0.12 + github.com/mattn/go-sqlite3 v1.14.7 + github.com/opentracing/opentracing-go v1.2.0 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pkg/errors v0.9.1 + github.com/prometheus/alertmanager v0.21.1-0.20210511232218-7301451eb94d + github.com/prometheus/client_golang v1.10.0 + github.com/prometheus/client_model v0.2.0 + github.com/prometheus/common v0.24.0 + github.com/prometheus/prometheus v1.8.2-0.20210421143221-52df5ef7a3be + github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 + github.com/robfig/cron/v3 v3.0.1 + github.com/russellhaering/goxmldsig v1.1.0 + github.com/smartystreets/goconvey v1.6.4 + github.com/stretchr/testify v1.7.0 + github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf + github.com/timberio/go-datemath v0.1.1-0.20200323150745-74ddef604fff + github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329 + github.com/uber/jaeger-client-go v2.27.0+incompatible + github.com/unknwon/com v1.0.1 + github.com/urfave/cli/v2 v2.3.0 + github.com/weaveworks/common v0.0.0-20210419092856-009d1eebd624 + github.com/xorcare/pointer v1.1.0 + github.com/yudai/gojsondiff v1.0.0 + go.opentelemetry.io/collector v0.25.0 + golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 + golang.org/x/exp v0.0.0-20210220032938-85be41e4509f // indirect + golang.org/x/net v0.0.0-20210421230115-4e50805a0758 + golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba + golang.org/x/tools v0.1.0 + gonum.org/v1/gonum v0.9.1 + google.golang.org/api v0.45.0 + google.golang.org/grpc v1.37.0 + google.golang.org/protobuf v1.26.0 + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/ini.v1 v1.62.0 + gopkg.in/ldap.v3 v3.1.0 + gopkg.in/macaron.v1 v1.4.0 + gopkg.in/mail.v2 v2.3.1 + gopkg.in/redis.v5 v5.2.9 + gopkg.in/square/go-jose.v2 v2.5.1 + gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + xorm.io/core v0.7.3 + xorm.io/xorm v0.8.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b95a5a7 --- /dev/null +++ b/go.sum @@ -0,0 +1,2681 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.43.0/go.mod h1:BOSR3VbTLkk6FDC/TcffxP4NF/FFBGA5ku+jvKOP7pg= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0 h1:at8Tk2zUz63cLPR0JPWm5vp77pEZmzxEQBEfRKn1VV8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigtable v1.1.0/go.mod h1:B6ByKcIdYmhoyDzmOnQxyOhN6r05qnewYIxxG6L0/b4= +cloud.google.com/go/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.3.0/go.mod h1:9IAwXhoyBJ7z9LcAwkj0/7NnPzYaPeZxxVp3zm+5IqA= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0 h1:6RRlFMv1omScs6iq2hfE3IvgE+l6RfJPampq8UZc5TU= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +code.cloudfoundry.org/clock v1.0.0/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= +contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs= +contrib.go.opencensus.io/exporter/prometheus v0.3.0/go.mod h1:rpCPVQKhiyH8oomWgm34ZmgIdZa8OVYO5WAIygPbBBE= +cuelang.org/go v0.3.2 h1:/Am5yFDwqnaEi+g942OPM1M4/qtfVSm49wtkQbeh5Z4= +cuelang.org/go v0.3.2/go.mod h1:jvMO35Q4D2D3m2ujAmKESICaYkjMbu5+D+2zIGuWTpQ= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/Azure/azure-amqp-common-go/v3 v3.0.0/go.mod h1:SY08giD/XbhTz07tJdpw1SoxQXHPN30+DI3Z04SYqyg= +github.com/Azure/azure-event-hubs-go/v3 v3.2.0/go.mod h1:BPIIJNH/l/fVHYq3Rm6eg4clbrULrQ3q7+icmqHyyLc= +github.com/Azure/azure-pipeline-go v0.1.8/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= +github.com/Azure/azure-pipeline-go v0.1.9/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= +github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= +github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= +github.com/Azure/azure-sdk-for-go v23.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v36.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v37.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v43.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v44.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v44.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v45.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v46.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v48.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v51.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v52.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-storage-blob-go v0.6.0/go.mod h1:oGfmITT1V6x//CswqY2gtAHND+xIP64/qL7a5QJix0Y= +github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= +github.com/Azure/azure-storage-queue-go v0.0.0-20181215014128-6ed74e755687/go.mod h1:K6am8mT+5iFXgingS9LUc7TmbsW6XBw3nxaRyaMyWc8= +github.com/Azure/go-amqp v0.12.6/go.mod h1:qApuH6OFTSKZFmCOxccvAv5rLizBQf4v8pRmG138DPo= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest v0.9.3-0.20191028180845-3492b2aff503/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0= +github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630= +github.com/Azure/go-autorest/autorest v0.11.2/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest v0.11.4/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest v0.11.10/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= +github.com/Azure/go-autorest/autorest v0.11.11/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= +github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= +github.com/Azure/go-autorest/autorest/adal v0.8.1-0.20191028180845-3492b2aff503/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= +github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/adal v0.8.3/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= +github.com/Azure/go-autorest/autorest/adal v0.9.2/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE= +github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= +github.com/Azure/go-autorest/autorest/azure/auth v0.4.2/go.mod h1:90gmfKdlmKgfjUpnCEpOJzsUEjrWDSLwHIG73tSXddM= +github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/to v0.3.0/go.mod h1:MgwOyqaIuKdG4TL/2ywSsIWKAfJfgHDo8ObuUk3t5sA= +github.com/Azure/go-autorest/autorest/to v0.3.1-0.20191028180845-3492b2aff503/go.mod h1:MgwOyqaIuKdG4TL/2ywSsIWKAfJfgHDo8ObuUk3t5sA= +github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= +github.com/Azure/go-autorest/autorest/validation v0.2.0/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI= +github.com/Azure/go-autorest/autorest/validation v0.2.1-0.20191028180845-3492b2aff503/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI= +github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/zstd v1.3.6-0.20190409195224-796139022798/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.4.4/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/FZambia/eagle v0.0.1 h1:FN1yTkPihMb5nE8SrlRjoCf7T9H9bTKJFQOm6ach2YU= +github.com/FZambia/eagle v0.0.1/go.mod h1:xq6u/JeNZ5/8mrAQ76MMhzNTodASh9FavQlCgg4j48w= +github.com/FZambia/sentinel v1.1.0 h1:qrCBfxc8SvJihYNjBWgwUI93ZCvFe/PJIPTHKmlp8a8= +github.com/FZambia/sentinel v1.1.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= +github.com/HdrHistogram/hdrhistogram-go v0.9.0/go.mod h1:nxrse8/Tzg2tg3DZcZjm6qEclQKK70g0KxO61gFFZD4= +github.com/HdrHistogram/hdrhistogram-go v1.0.1 h1:GX8GAYDuhlFQnI2fRDHQhTlkHMz8bEn0jTI6LJU0mpw= +github.com/HdrHistogram/hdrhistogram-go v1.0.1/go.mod h1:BWJ+nMSHY3L41Zj7CA3uXnloDp7xxV0YvstAE7nKTaM= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/Masterminds/squirrel v0.0.0-20161115235646-20f192218cf5/go.mod h1:xnKTFzjGUiZtiOagBsfnvomW+nJg2usB1ZpordQWqNM= +github.com/Mellanox/rdmamap v0.0.0-20191106181932-7c3c4763a6ee/go.mod h1:jDA6v0TUYrFEIAE5uGJ29LQOeONIgMdP4Rkqb8HUnPM= +github.com/Microsoft/ApplicationInsights-Go v0.4.2/go.mod h1:CukZ/G66zxXtI+h/VcVn3eVVDGDHfXM2zVILF7bMmsg= +github.com/Microsoft/go-winio v0.4.9/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/OneOfOne/xxhash v1.2.6 h1:U68crOE3y3MPttCMQGywZOLrTeF5HHJ3/vDBCJn9/bA= +github.com/OneOfOne/xxhash v1.2.6/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/sarama v1.22.2-0.20190604114437-cd910a683f9f/go.mod h1:XLH1GYJnLVE0XCr6KdJGVJRTwY30moWNJ4sERjXX6fs= +github.com/Shopify/sarama v1.27.1/go.mod h1:g5s5osgELxgM+Md9Qni9rzo7Rbt+vvFQI4bt/Mc93II= +github.com/Shopify/sarama v1.28.0/go.mod h1:j/2xTrU39dlzBmsxF1eQ2/DdWrxyBCl6pzz7a81o/ZY= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f h1:HR5nRmUQgXrwqZOwZ2DAc/aCi3Bu3xENpspW935vxu0= +github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3HiCrHjHBdcm6E83vGaXh1KomZMA2P6aeo3hKx/wg0= +github.com/aerospike/aerospike-client-go v1.27.0/go.mod h1:zj8LBEnWBDOVEIJt8LvaRvDG5ARAoa5dBeHaB472NRc= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/aliyun/aliyun-oss-go-sdk v2.0.4+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/amir/raidman v0.0.0-20170415203553-1ccc43bfb9c9/go.mod h1:eliMa/PW+RDr2QLWRmLH1R1ZA4RInpmvOzDDXtaIZkc= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antonmedv/expr v1.8.9/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8= +github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= +github.com/apache/arrow/go/arrow v0.0.0-20200629181129-68b1273cbbf7/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= +github.com/apache/arrow/go/arrow v0.0.0-20210223225224-5bea62493d91 h1:rbe942bXzd2vnds4y9fYQL8X4yFltXoZsKW7KtG+TFM= +github.com/apache/arrow/go/arrow v0.0.0-20210223225224-5bea62493d91/go.mod h1:c9sxoIT3YgLxH4UhLOCKaBlEojuMhVYpk4Ntv3opUTQ= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/aristanetworks/glog v0.0.0-20191112221043-67e8567f59f3/go.mod h1:KASm+qXFKs/xjSoWn30NrWBBvdTTQq+UjkhjEJHfSFA= +github.com/aristanetworks/goarista v0.0.0-20190325233358-a123909ec740/go.mod h1:D/tb0zPVXnP7fmsLZjtdUhSsumbK/ij54UXjjVgMGxQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= +github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= +github.com/armon/go-metrics v0.3.3/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-metrics v0.3.6 h1:x/tmtOF9cDBoXH7XoAGOz2qqm1DknFD1590XmD/DUJ8= +github.com/armon/go-metrics v0.3.6/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= +github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-lambda-go v1.17.0/go.mod h1:FEwgPLE6+8wcGBTe5cJN3JWurd1Ztm9zN4jsXsjzKKw= +github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= +github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.22.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.33.5/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.33.12/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.34.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/aws/aws-sdk-go v1.34.34/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/aws/aws-sdk-go v1.35.5/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= +github.com/aws/aws-sdk-go v1.35.30/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= +github.com/aws/aws-sdk-go v1.35.31/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.37.8/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.38.3/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.38.34 h1:JSAyS6hSDLbRmCAz9VAkwDf5oh/olt9mBTrVBWGJcU8= +github.com/aws/aws-sdk-go v1.38.34/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg= +github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmatcuk/doublestar v1.2.2/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0= +github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/bsm/sarama-cluster v2.1.13+incompatible/go.mod h1:r7ao+4tTNXvWm+VRpRJchr2kQhqxgmAp2iEX5W96gMM= +github.com/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= +github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee h1:BnPxIde0gjtTnc9Er7cxvBk8DHLWhEux0SxayC8dP6I= +github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= +github.com/caio/go-tdigest v2.3.0+incompatible/go.mod h1:sHQM/ubZStBUmF1WbB8FAm8q9GjDajLC5T7ydxE3JHI= +github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v0.0.0-20181003080854-62661b46c409/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v1.0.0/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= +github.com/cenkalti/backoff/v4 v4.1.0 h1:c8LkOFQTzuO0WBM/ae5HdGQuZPfPxp7lqBRwQRm4fSc= +github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/centrifugal/centrifuge v0.17.0 h1:ANZMhcR8pFbRUPdv45nrIhhZcsSOdtshT3YM4v1/NHY= +github.com/centrifugal/centrifuge v0.17.0/go.mod h1:AEFs3KPGRpvX1jCe24NDlGWQu7DPa7vdzeY/aUluOm0= +github.com/centrifugal/centrifuge-go v0.7.1/go.mod h1:G8cXpoTVd8l6CMHh9LWyUJOEfu6cjrm4SGdT36E15Hc= +github.com/centrifugal/protocol v0.3.5/go.mod h1:2YbBCaDwQHl37ErRdMrKSj18X2yVvpkQYtSX6aVbe5A= +github.com/centrifugal/protocol v0.5.0 h1:h71u2Q53yhplftmUk1tjc+Mu6TKJ/eO3YRD3h7Qjvj4= +github.com/centrifugal/protocol v0.5.0/go.mod h1:ru2N4pwiND/jE+XLtiLYbUo3YmgqgniGNW9f9aRgoVI= +github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/cespare/xxhash v0.0.0-20181017004759-096ff4a8a059/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/chromedp/cdproto v0.0.0-20200116234248-4da64dd111ac/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= +github.com/chromedp/cdproto v0.0.0-20200424080200-0de008e41fa0/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= +github.com/chromedp/chromedp v0.5.3/go.mod h1:YLdPtndaHQ4rCpSpBG+IPpy9JvX0VD+7aaLxYgYj28w= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/cisco-ie/nx-telemetry-proto v0.0.0-20190531143454-82441e232cf6/go.mod h1:ugEfq4B8T8ciw/h5mCkgdiDRFS4CkqqhH2dymDB4knc= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/apd/v2 v2.0.1 h1:y1Rh3tEU89D+7Tgbw+lp52T6p/GJLpDmNvr10UWqLTE= +github.com/cockroachdb/apd/v2 v2.0.1/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= +github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= +github.com/cockroachdb/datadriven v0.0.0-20190531201743-edce55837238/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= +github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= +github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/containerd/containerd v1.2.7/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cortexproject/cortex v0.6.1-0.20200228110116-92ab6cbe0995/go.mod h1:3Xa3DjJxtpXqxcMGdk850lcIRb81M0fyY1MQ6udY134= +github.com/cortexproject/cortex v1.2.1-0.20200805064754-d8edc95e2c91/go.mod h1:PVPxNLrxKH+yc8asaJOxuz7TiRmMizFfnSMOnRzM6oM= +github.com/cortexproject/cortex v1.3.1-0.20200923145333-8587ea61fe17/go.mod h1:dJ9gpW7dzQ7z09cKtNN9PfebumgyO4dtNdFQ6eQEed0= +github.com/cortexproject/cortex v1.4.1-0.20201030080541-83ad6df2abea/go.mod h1:kXo5F3jlF7Ky3+I31jt/bXTzOlQjl2X/vGDpy0RY1gU= +github.com/cortexproject/cortex v1.5.1-0.20201111110551-ba512881b076/go.mod h1:zFBGVsvRBfVp6ARXZ7pmiLaGlbjda5ZnA4Y6qSJyrQg= +github.com/cortexproject/cortex v1.6.1-0.20210108144208-6c2dab103f20/go.mod h1:fOsaeeFSyWrjd9nFJO8KVUpsikcxnYsjEzQyjURBoQk= +github.com/cortexproject/cortex v1.6.1-0.20210215155036-dfededd9f331/go.mod h1:8bRHNDawVx8te5lIqJ+/AcNTyfosYNC34Qah7+jX/8c= +github.com/cortexproject/cortex v1.7.1-0.20210224085859-66d6fb5b0d42/go.mod h1:u2dxcHInYbe45wxhLoWVdlFJyDhXewsMcxtnbq/QbH4= +github.com/cortexproject/cortex v1.7.1-0.20210316085356-3fedc1108a49/go.mod h1:/DBOW8TzYBTE/U+O7Whs7i7E2eeeZl1iRVDtIqxn5kg= +github.com/cortexproject/cortex v1.8.1-0.20210422151339-cf1c444e0905/go.mod h1:xxm4/CLvTmDxwE7yXwtClR4dIvkG4S09o5DygPOgc1U= +github.com/cortexproject/cortex v1.8.2-0.20210428155238-d382e1d80eaf h1:n91VdkD+va1RmEwqI4RE5GAFJh3tD7/19bIJGSspXYU= +github.com/cortexproject/cortex v1.8.2-0.20210428155238-d382e1d80eaf/go.mod h1:6SCzTC6fT7CljgOb3nvpu/tBmhb+mg6ThiYPpkCqSDE= +github.com/couchbase/go-couchbase v0.0.0-20180501122049-16db1f1fe037/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U= +github.com/couchbase/gomemcached v0.0.0-20180502221210-0da75df14530/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c= +github.com/couchbase/goutils v0.0.0-20180530154633-e865a1461c8a/go.mod h1:BQwMFlJzDjFDG3DJUdU0KORxn88UlsOULuxLExMh3Hs= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/httperr v0.0.0-20190612203328-a946449404da/go.mod h1:+rmNIXRvYMqLQeR4DHyTvs6y0MEMymTz4vyFpFkKTPs= +github.com/crewjam/saml v0.4.6-0.20201227203850-bca570abb2ce h1:pAuTpLhCqC20s2RLhUirfw606jReW+8z2U5EvG+0S7E= +github.com/crewjam/saml v0.4.6-0.20201227203850-bca570abb2ce/go.mod h1:/gCaeLf13J8/621RNZ6TaExji/8xCWcn6UmdJ57wURQ= +github.com/crossdock/crossdock-go v0.0.0-20160816171116-049aabb0122b/go.mod h1:v9FBN7gdVTpiD/+LZ7Po0UKvROyT87uLVxTHVky/dlQ= +github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= +github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= +github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= +github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc= +github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4= +github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A= +github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= +github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE= +github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ= +github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc= +github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8= +github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= +github.com/deepmap/oapi-codegen v1.3.13 h1:9HKGCsdJqE4dnrQ8VerFS0/1ZOJPmAhN+g8xgp8y3K4= +github.com/deepmap/oapi-codegen v1.3.13/go.mod h1:WAmG5dWY8/PYHt4vKxlt90NsbHMAOCiteYKZMiIRfOo= +github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= +github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= +github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dgryski/go-bitstream v0.0.0-20180413035011-3522498ce2c8/go.mod h1:VMaSuZ+SZcx/wljOQKvp5srsbCiKDEb6K2wC4+PiBmQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dgryski/go-sip13 v0.0.0-20200911182023-62edffca9245/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= +github.com/digitalocean/godo v1.37.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.38.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.42.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.42.1/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.46.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.52.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.57.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.58.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= +github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/docker/distribution v2.6.0-rc.1.0.20170726174610-edc3ab29cdff+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v0.7.3-0.20190103212154-2b7e084dc98b/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v0.7.3-0.20190817195342-4760db040282/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v17.12.0-ce-rc1.0.20200706150819-a40b877fbb9e+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v17.12.0-ce-rc1.0.20200916142827-bd33bbf0497b+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v20.10.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v20.10.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-plugins-helpers v0.0.0-20181025120712-1e6269c305b8/go.mod h1:LFyLie6XcDbyKGeVK6bHe+9aJTYCxWLBg5IrJZOaXKA= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libnetwork v0.8.0-dev.2.0.20181012153825-d7b61745d166/go.mod h1:93m0aTqz6z+g32wla4l4WxTrdtvBRmVzYRkYvasA5Z8= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/drone/envsubst v1.0.2/go.mod h1:bkZbnc/2vh1M12Ecn7EYScpI4YGYU0etwLJICOWi8Z0= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elastic/go-sysinfo v1.0.1/go.mod h1:O/D5m1VpYLwGjCYzEt63g3Z1uO3jXfwyzzjiW90t8cY= +github.com/elastic/go-sysinfo v1.1.1/go.mod h1:i1ZYdU10oLNfRzq4vq62BEwD2fH8KaWh6eh0ikPT9F0= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/ema/qdisc v0.0.0-20190904071900-b82c76788043/go.mod h1:ix4kG2zvdUd8kEKSW0ZTr1XLks0epFpI4j745DXxlNE= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/proto v1.6.15 h1:XbpwxmuOPrdES97FrSfpyy67SSCV/wBIKXqgJzh6hNw= +github.com/emicklei/proto v1.6.15/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ericchiang/k8s v1.2.0/go.mod h1:/OmBgSq2cd9IANnsGHGlEz27nwMZV2YxlpXuQtU3Bz4= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/evanphx/json-patch v0.0.0-20200808040245-162e5629780b/go.mod h1:NAJj0yf/KaRKURN6nyi7A9IZydMivZEm9oQLWNjfKDc= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b h1:V6c4/dSTNhSaNn4c5ulbakfv277qCvs7byFYv7P83iQ= +github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b/go.mod h1:oO8UHw+fDHjDsk4CTy/E96WDzFUYozAtBAaGNoVL0+c= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/structtag v0.0.0-20150214074306-217e25fb9691 h1:KnnwHN59Jxec0htA2pe/i0/WI9vxXLQifdhBrP3lqcQ= +github.com/facebookgo/structtag v0.0.0-20150214074306-217e25fb9691/go.mod h1:sKLL1iua/0etWfo/nPCmyz+v2XDMXy+Ho53W7RAuZNY= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= +github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fatih/structtag v1.1.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/fgprof v0.9.1/go.mod h1:7/HK6JFtFaARhIljgP2IV8rJLIoHDoOYoUphsnGvqxE= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluent/fluent-bit-go v0.0.0-20190925192703-ea13c021720c/go.mod h1:WQX+afhrekY9rGK+WT4xvKSlzmia9gDoLYu4GGYGASQ= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/frankban/quicktest v1.7.3/go.mod h1:V1d2J5pfxYH6EjBAgSK7YNXcXlTWxUHdE1sVDXkjnig= +github.com/frankban/quicktest v1.10.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s= +github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsouza/fake-gcs-server v1.7.0/go.mod h1:5XIRs4YvwNbNoz+1JF8j6KLAyDh7RHGAyAK3EP2EsNk= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/gchaincl/sqlhooks v1.3.0 h1:yKPXxW9a5CjXaVf2HkQn6wn7TZARvbAOAelr3H8vK2Y= +github.com/gchaincl/sqlhooks v1.3.0/go.mod h1:9BypXnereMT0+Ys8WGWHqzgkkOfHIhyeUCqXC24ra34= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/getkin/kin-openapi v0.13.0/go.mod h1:WGRs2ZMM1Q8LR1QBEwUxC6RJEfaBcD0s+pcEVXFuAjw= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g= +github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/glinton/ping v0.1.4-0.20200311211934-5ac87da8cd96/go.mod h1:uY+1eqFUyotrQxF1wYFNtMeHp/swbYRsoGzfcPZ8x3o= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= +github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= +github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b h1:U65wj9SF7qUBTGrnt6VxbHCT0Dw8dz4uch52G+5SdfA= +github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b/go.mod h1:AG8Z6qkQM8s47aUDJOco/SNwJ8Czif2hMm7rc0abDog= +github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 h1:YSIA98PevNf1NtCa/J6cz7gjzpz99WVAOa9Eg0klKps= +github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07/go.mod h1://cJFfDp/70L0oTNAMB+M8Jd0rpuIx/55iARuJ6StwE= +github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI= +github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.17.2/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.4/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= +github.com/go-openapi/analysis v0.19.10/go.mod h1:qmhS3VNFxBlquFJ0RGoDtylO9y4pgTAUNE9AEEMdlJQ= +github.com/go-openapi/analysis v0.19.14/go.mod h1:zN0kY6i38wo2LQOwltVyMk61bqlqOm86n1/Iszo8F8Y= +github.com/go-openapi/analysis v0.19.16/go.mod h1:GLInF007N83Ad3m8a/CbQ5TPzdnGT7workfHwuVjNVk= +github.com/go-openapi/analysis v0.20.0 h1:UN09o0kNhleunxW7LR+KnltD0YrJ8FF03pSqvAN3Vro= +github.com/go-openapi/analysis v0.20.0/go.mod h1:BMchjvaHDykmRMsK40iPtvyOfFdMMxlOmQr9FBZk+Og= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.17.2/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.3/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.4/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.6/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.19.7/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.19.8/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.20.0 h1:Sxpo9PjEHDzhs3FbnGNonvDgWcMW2U7wGTcDDSFSceM= +github.com/go-openapi/errors v0.20.0/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.17.2/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.17.2/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= +github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.17.2/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/loads v0.19.3/go.mod h1:YVfqhUCdahYwR3f3iiwQLhicVRvLlU/WO5WPaZvcvSI= +github.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk= +github.com/go-openapi/loads v0.19.5/go.mod h1:dswLCAdonkRufe/gSUC3gN8nTSaB9uaS2es0x5/IbjY= +github.com/go-openapi/loads v0.19.6/go.mod h1:brCsvE6j8mnbmGBh103PT/QLHfbyDxA4hsKvYBNEGVc= +github.com/go-openapi/loads v0.19.7/go.mod h1:brCsvE6j8mnbmGBh103PT/QLHfbyDxA4hsKvYBNEGVc= +github.com/go-openapi/loads v0.20.0/go.mod h1:2LhKquiE513rN5xC6Aan6lYOSddlL8Mp20AW9kpviM4= +github.com/go-openapi/loads v0.20.2 h1:z5p5Xf5wujMxS1y8aP+vxwW5qYT2zdJBbXKmQUG3lcc= +github.com/go-openapi/loads v0.20.2/go.mod h1:hTVUotJ+UonAMMZsvakEgmWKgtulweO9vYP2bQYKA/o= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.18.0/go.mod h1:uI6pHuxWYTy94zZxgcwJkUWa9wbIlhteGfloI10GD4U= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/runtime v0.19.3/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/runtime v0.19.15/go.mod h1:dhGWCTKRXlAfGnQG0ONViOZpjfg0m2gUt9nTQPQZuoo= +github.com/go-openapi/runtime v0.19.16/go.mod h1:5P9104EJgYcizotuXhEuUrzVc+j1RiSjahULvYmlv98= +github.com/go-openapi/runtime v0.19.24/go.mod h1:Lm9YGCeecBnUUkFTxPC4s1+lwrkJ0pthx8YvyjCfkgk= +github.com/go-openapi/runtime v0.19.26/go.mod h1:BvrQtn6iVb2QmiVXRsFAm6ZCAZBpbVKFfN6QWCp582M= +github.com/go-openapi/runtime v0.19.28 h1:9lYu6axek8LJrVkMVViVirRcpoaCxXX7+sSvmizGVnA= +github.com/go-openapi/runtime v0.19.28/go.mod h1:BvrQtn6iVb2QmiVXRsFAm6ZCAZBpbVKFfN6QWCp582M= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.17.2/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/spec v0.19.6/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.19.7/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.19.8/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA= +github.com/go-openapi/spec v0.19.15/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU= +github.com/go-openapi/spec v0.20.0/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU= +github.com/go-openapi/spec v0.20.1/go.mod h1:93x7oh+d+FQsmsieroS4cmR3u0p/ywH649a3qwC9OsQ= +github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls= +github.com/go-openapi/spec v0.20.3 h1:uH9RQ6vdyPSs2pSy9fL8QPspDF2AMIMPtmK5coSSjtQ= +github.com/go-openapi/spec v0.20.3/go.mod h1:gG4F8wdEDN+YPBMVnzE85Rbhf+Th2DTvA9nFPQ5AYEg= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.17.2/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/strfmt v0.19.2/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.4/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/strfmt v0.19.11/go.mod h1:UukAYgTaQfqJuAFlNxxMWNvMYiwiXtLsF2VwmoFtbtc= +github.com/go-openapi/strfmt v0.20.0/go.mod h1:UukAYgTaQfqJuAFlNxxMWNvMYiwiXtLsF2VwmoFtbtc= +github.com/go-openapi/strfmt v0.20.1 h1:1VgxvehFne1mbChGeCmZ5pc0LxUf6yaACVSIYAR91Xc= +github.com/go-openapi/strfmt v0.20.1/go.mod h1:43urheQI9dNtE5lTZQfuFJvjYJKPrxicATpEfZwHUNk= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.17.2/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.4/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.7/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/swag v0.19.9/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY= +github.com/go-openapi/swag v0.19.12/go.mod h1:eFdyEBkTdoAf/9RXBvj4cr1nH7GD8Kzo5HTt47gr72M= +github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/validate v0.17.2/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= +github.com/go-openapi/validate v0.19.3/go.mod h1:90Vh6jjkTn+OT1Eefm0ZixWNFjhtOH7vS9k0lo6zwJo= +github.com/go-openapi/validate v0.19.8/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= +github.com/go-openapi/validate v0.19.10/go.mod h1:RKEZTUWDkxKQxN2jDT7ZnZi2bhZlbNMAuKvKB+IaGx8= +github.com/go-openapi/validate v0.19.12/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0waH08tGe6kAQ4= +github.com/go-openapi/validate v0.19.14/go.mod h1:PdGrHe0rp6MG3A1SrAY/rIHATqzJEEhohGE1atLkBEQ= +github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI= +github.com/go-openapi/validate v0.20.1/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0= +github.com/go-openapi/validate v0.20.2 h1:AhqDegYV3J3iQkMPJSXkvzymHKMTw0BST3RK3hTT4ts= +github.com/go-openapi/validate v0.20.2/go.mod h1:e7OJoKNgd0twXZwIn0A43tHbvIcr/rZIVCbJBpTUoY0= +github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-redis/redis/v8 v8.0.0-beta.10.0.20200905143926-df7fe4e2ce72/go.mod h1:CJP1ZIHwhosNYwIdaHPZK9vHsM3+roNBaZ7U9Of1DXc= +github.com/go-redis/redis/v8 v8.2.3/go.mod h1:ysgGY09J/QeDYbu3HikWEIPCwaeOkuNoTgKayTEaEOw= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y= +github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= +github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/goburrow/modbus v0.1.0/go.mod h1:Kx552D5rLIS8E7TyUwQ/UdHEqvX5T8tyiGBTlzMcZBg= +github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0= +github.com/gocql/gocql v0.0.0-20200121121104-95d072f1b5bb/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/gocql/gocql v0.0.0-20200228163523-cd4b606dd2fb/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/gocql/gocql v0.0.0-20200526081602-cd04bd7f22a7/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/godbus/dbus v0.0.0-20190402143921-271e53dc4968/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v2.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/googleapis v1.1.0 h1:kFkMAZBNAn4j7K0GiZr8cRYzejq68VbheufiV3YuyFI= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.2.2-0.20190730201129-28a6bbf47e48/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.0.3 h1:WkVBY59mw7qUNTr/bLwO7J2vesJ0rQ2C3tMXrTd3w5M= +github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= +github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.3-0.20201103224600-674baa8c7fc3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/gomodule/redigo v1.8.4/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/flatbuffers v1.11.0 h1:O7CEyB8Cb3/DmtxODGtLHcEvpr81Jm5qLg/hsHnxA2A= +github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200507031123-427632fa3b1c/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200615235658-03e1cf38a040/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201007051231-1066cbb265c7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201117184057-ae444373da19/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210208152844-1612e9be7af6/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210323184331-8eee2492667d/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go v2.0.2+incompatible h1:silFMLAnr330+NRuag/VjIGF7TLp/LBrV2CJKFLWEww= +github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= +github.com/googleapis/gnostic v0.4.0/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/gopcua/opcua v0.1.12/go.mod h1:a6QH4F9XeODklCmWuvaOdL8v9H0d73CEKUHWVZLQyE8= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gophercloud/gophercloud v0.3.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gophercloud/gophercloud v0.6.0/go.mod h1:GICNByuaEBibcjmjvI7QvYJSZEbGkcYwAR7EZK2WMqM= +github.com/gophercloud/gophercloud v0.11.0/go.mod h1:gmC5oQqMDOMO1t1gq5DquX/yAU808e/4mzjjDA76+Ss= +github.com/gophercloud/gophercloud v0.12.0/go.mod h1:gmC5oQqMDOMO1t1gq5DquX/yAU808e/4mzjjDA76+Ss= +github.com/gophercloud/gophercloud v0.13.0/go.mod h1:VX0Ibx85B60B5XOrZr6kaNwrmPUzcmMpwxvQ1WQIIWM= +github.com/gophercloud/gophercloud v0.14.0/go.mod h1:VX0Ibx85B60B5XOrZr6kaNwrmPUzcmMpwxvQ1WQIIWM= +github.com/gophercloud/gophercloud v0.15.0/go.mod h1:VX0Ibx85B60B5XOrZr6kaNwrmPUzcmMpwxvQ1WQIIWM= +github.com/gophercloud/gophercloud v0.16.0/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075O6i+LY+pXsKCBsb4= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de h1:F7WD09S8QB4LrkEpka0dFPLSotH11HRpCsLIbIcJ7sU= +github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= +github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= +github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 h1:GplhUk6Xes5JIhUUrggPcPBhOn+eT8+WsHiebvq7GgA= +github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/grafana/grafana-aws-sdk v0.4.0 h1:JmTaXfOJ/ydHSWH9kEt8Yhfb9kAhIW4LUOO3SWCviYg= +github.com/grafana/grafana-aws-sdk v0.4.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbRkcTyQYqMiAj7G5U= +github.com/grafana/grafana-live-sdk v0.0.6-0.20210513051437-bf97d7ff8f21 h1:TZra/rWUGdTSb/nehsxExANpvWAgRTxzRjuVNTSp+Q4= +github.com/grafana/grafana-live-sdk v0.0.6-0.20210513051437-bf97d7ff8f21/go.mod h1:f15hHmWyLdFjmuWLsjeKeZnq/HnNQ3QkoPcaEww45AY= +github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag= +github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To= +github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= +github.com/grafana/grafana-plugin-sdk-go v0.91.0/go.mod h1:Ot3k7nY7P6DXmUsDgKvNB7oG1v7PRyTdmnYVoS554bU= +github.com/grafana/grafana-plugin-sdk-go v0.97.0 h1:V9307Grs2QLvzcldC4hwe3Q1jh6vEMIwONq+l0iy+mk= +github.com/grafana/grafana-plugin-sdk-go v0.97.0/go.mod h1:kgJSx8txPM+3lxLdSp+E9mdnB0xbXkM7VWr7FSHJY0k= +github.com/grafana/loki v1.6.2-0.20210510132741-f408e05ad426 h1:fVUMdXAjiHsx71Twl/oie1OLDH+dxL7+mBdQK/H2Wgs= +github.com/grafana/loki v1.6.2-0.20210510132741-f408e05ad426/go.mod h1:IfQ9BWq2sVAk3iKB4Pahz6QNTs5D4WpfJj/AY8xzmNw= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.1.0/go.mod h1:f5nM7jw/oeRSadq3xCzHAvxcr8HZnzsqU6ILg/0NiiE= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.0/go.mod h1:mJzapYve32yjrKlk9GbyCZHuPgZsrbyIbyKhSzOpg6s= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-middleware/providers/kit/v2 v2.0.0-20201002093600-73cf2ae9d891/go.mod h1:516cTXxZzi4NBUBbKcwmO4Eqbb6GHAEd3o4N+GYyCBY= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-20200501113911-9a95f0fdbfea/go.mod h1:GugMBs30ZSAkckqXEAIEGyYdDH6EgqowG8ppA3Zt+AY= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.2.0.20201207153454-9f6bf00c00a7/go.mod h1:GhphxcdlaRyAuBSvo6rV71BvQcvB/vuX8ugCyybuS2k= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= +github.com/grpc-ecosystem/grpc-gateway v1.4.1/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.4/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.12.1/go.mod h1:8XEsbTttt/W+VvjtQhLACqCisSPWTxCZ7sBRjU6iH9c= +github.com/grpc-ecosystem/grpc-gateway v1.14.5/go.mod h1:UJ0EZAp832vCd54Wev9N1BMKEyvcZ5+IM0AwDrnlkEc= +github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw= +github.com/grpc-ecosystem/grpc-gateway v1.15.0/go.mod h1:vO11I9oWA+KsxmfFQPhLnnIb1VDE24M+pdxZFiuZcA8= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/harlow/kinesis-consumer v0.3.1-0.20181230152818-2f58b136fee0/go.mod h1:dk23l2BruuUzRP8wbybQbPn3J7sZga2QHICCeaEy5rQ= +github.com/hashicorp/consul v1.2.1/go.mod h1:mFrjN1mfidgJfYP1xrJCF+AfRhr6Eaqhb2+sfyn/OOI= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= +github.com/hashicorp/consul/api v1.4.0/go.mod h1:xc8u05kyMa3Wjr9eEAsIAo3dg8+LywT5E/Cl7cNS5nU= +github.com/hashicorp/consul/api v1.5.0/go.mod h1:LqwrLNW876eYSuUOo4ZLHBcdKc038txr/IMfbLPATa4= +github.com/hashicorp/consul/api v1.6.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn6r2DXKhuDNFg= +github.com/hashicorp/consul/api v1.7.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn6r2DXKhuDNFg= +github.com/hashicorp/consul/api v1.8.1/go.mod h1:sDjTOq0yUyv5G4h+BqSea7Fn6BU+XbolEz1952UB+mk= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/sdk v0.4.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/consul/sdk v0.5.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/consul/sdk v0.6.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/consul/sdk v0.7.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.12.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.15.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.16.0 h1:uCeOEwSWGMwhJUdpUjk+1cVKIEfGu2/1nFXukimi2MU= +github.com/hashicorp/go-hclog v0.16.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.2.0 h1:l6UW37iCXwZkZoAbEYnptSHVE/cQ5bOTPYG5W3vf9+8= +github.com/hashicorp/go-immutable-radix v1.2.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= +github.com/hashicorp/go-plugin v1.2.2/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYtXdgmf1AVNs0= +github.com/hashicorp/go-plugin v1.4.0 h1:b0O7rs5uiJ99Iu9HugEzsM67afboErkHUWddUSpUO3A= +github.com/hashicorp/go-plugin v1.4.0/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90/go.mod h1:o4zcYY1e0GEZI6eSEr+43QDYmuGglw1qSO6qdHUHCgg= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= +github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.1.4/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.1.5/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.2.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.2.3 h1:BwZa5IjREr75J0am7nblP+X5i95Rmp8EEbMI5vkUWdA= +github.com/hashicorp/memberlist v0.2.3/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.8.1/go.mod h1:h/Ru6tmZazX7WO/GDmwdpS975F019L4t5ng5IgwbNrE= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/serf v0.8.3/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= +github.com/hashicorp/serf v0.8.5/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= +github.com/hashicorp/serf v0.9.0/go.mod h1:YL0HO+FifKOW2u1ke99DGVu1zhcpZzNwrLIqBC7vbYU= +github.com/hashicorp/serf v0.9.3/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d h1:W+SIwDdl3+jXWeidYySAgzytE3piq6GumXeBjFBG67c= +github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hetznercloud/hcloud-go v1.21.1/go.mod h1:xng8lbDUg+xM1dgc0yGHX5EeqbwIq7UYlMWMTx3SQVg= +github.com/hetznercloud/hcloud-go v1.22.0/go.mod h1:xng8lbDUg+xM1dgc0yGHX5EeqbwIq7UYlMWMTx3SQVg= +github.com/hetznercloud/hcloud-go v1.23.1/go.mod h1:xng8lbDUg+xM1dgc0yGHX5EeqbwIq7UYlMWMTx3SQVg= +github.com/hetznercloud/hcloud-go v1.24.0/go.mod h1:3YmyK8yaZZ48syie6xpm3dt26rtB6s65AisBHylXYFA= +github.com/hodgesds/perf-utils v0.0.8/go.mod h1:F6TfvsbtrF88i++hou29dTXlI2sfsJv+gRZDtmTJkAs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/igm/sockjs-go/v3 v3.0.0 h1:4wLoB9WCnQ8RI87cmqUH778ACDFVmRpkKRCWBeuc+Ww= +github.com/igm/sockjs-go/v3 v3.0.0/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec h1:CGkYB1Q7DSsH/ku+to+foV4agt2F2miquaLUgF6L178= +github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/flux v0.65.0/go.mod h1:BwN2XG2lMszOoquQaFdPET8FRQfrXiZsWmcMO9rkaVY= +github.com/influxdata/flux v0.65.1/go.mod h1:J754/zds0vvpfwuq7Gc2wRdVwEodfpCFM7mYlOw2LqY= +github.com/influxdata/go-syslog/v2 v2.0.1/go.mod h1:hjvie1UTaD5E1fTnDmxaCw8RRDrT4Ve+XHr5O2dKSCo= +github.com/influxdata/go-syslog/v3 v3.0.1-0.20201128200927-a1889d947b48/go.mod h1:aXdIdfn2OcGnMhOTojXmwZqXKgC3MU5riiNvzwwG9OY= +github.com/influxdata/influxdb v1.7.7/go.mod h1:qZna6X/4elxqT3yI9iZYdZrWWdeFOOprn86kgg4+IzY= +github.com/influxdata/influxdb v1.8.0/go.mod h1:SIzcnsjaHRFpmlxpJ4S3NT64qtEKYweNTUMb/vh0OMQ= +github.com/influxdata/influxdb v1.8.1/go.mod h1:SIzcnsjaHRFpmlxpJ4S3NT64qtEKYweNTUMb/vh0OMQ= +github.com/influxdata/influxdb v1.8.2/go.mod h1:SIzcnsjaHRFpmlxpJ4S3NT64qtEKYweNTUMb/vh0OMQ= +github.com/influxdata/influxdb v1.8.3/go.mod h1:JugdFhsvvI8gadxOI6noqNeeBHvWNTbfYGtiAn+2jhI= +github.com/influxdata/influxdb v1.8.4/go.mod h1:JugdFhsvvI8gadxOI6noqNeeBHvWNTbfYGtiAn+2jhI= +github.com/influxdata/influxdb-client-go/v2 v2.2.3 h1:082jdJ5t1CFeo0rpGQvKAK1mONVSbFhL4finWA5bRM8= +github.com/influxdata/influxdb-client-go/v2 v2.2.3/go.mod h1:fa/d1lAdUHxuc1jedx30ZfNG573oQTQmUni3N6pcW+0= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/influxdata/influxql v1.1.0/go.mod h1:KpVI7okXjK6PRi3Z5B+mtKZli+R1DnZgb3N+tzevNgo= +github.com/influxdata/influxql v1.1.1-0.20200828144457-65d3ef77d385/go.mod h1:gHp9y86a/pxhjJ+zMjNXiQAA197Xk9wLxaz+fGG+kWk= +github.com/influxdata/line-protocol v0.0.0-20180522152040-32c6aa80de5e/go.mod h1:4kt73NQhadE3daL3WhR5EJ/J2ocX0PZzwxQ0gXJ7oFE= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 h1:vilfsDSy7TDxedi9gyBkMvAirat/oRcL0lFdJBf6tdM= +github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/influxdata/promql/v2 v2.12.0/go.mod h1:fxOPu+DY0bqCTCECchSRtWfc+0X19ybifQhZoQNF5D8= +github.com/influxdata/roaring v0.4.13-0.20180809181101-fc520f41fab6/go.mod h1:bSgUQ7q5ZLSO+bKBGqJiCBGAl+9DxyW63zLTujjUlOE= +github.com/influxdata/tail v1.0.1-0.20200707181643-03a791b270e4/go.mod h1:VeiWgI3qaGdJWust2fP27a6J+koITo/1c/UhxeOxgaM= +github.com/influxdata/tdigest v0.0.0-20181121200506-bf2b5ad3c0a9/go.mod h1:Js0mqiSBE6Ffsg94weZZ2c+v/ciT8QRHFOap7EKDrR0= +github.com/influxdata/telegraf v1.16.3/go.mod h1:fX/6k7qpIqzVPWyeIamb0wN5hbwc0ANUaTS80lPYFB8= +github.com/influxdata/toml v0.0.0-20190415235208-270119a8ce65/go.mod h1:zApaNFpP/bTpQItGZNNUMISDMDAnTXu9UqJ4yT3ocz8= +github.com/influxdata/usage-client v0.0.0-20160829180054-6d3895376368/go.mod h1:Wbbw6tYNvwa5dlB6304Sd+82Z3f7PmVZHVKU637d4po= +github.com/influxdata/wlog v0.0.0-20160411224016-7c63b0a71ef8/go.mod h1:/2NMgWB1DHM1ti/gqhOlg+LJeBVk6FqR5aVGYY0hlwI= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= +github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jackc/pgx v3.6.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jaegertracing/jaeger v1.22.0/go.mod h1:WnwW68MjJEViSLRQhe0nkIsBDaF3CzfFd8wJcpJv24k= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v0.0.0-20190328161633-dc7c13fece03/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= +github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= +github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.2.1/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/joncrlsn/dque v2.2.1-0.20200515025108-956d14155fa2+incompatible/go.mod h1:hDZb8oMj3Kp8MxtbNLg9vrtAUDHjgI1yZvqivT4O8Iw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= +github.com/jsimonetti/rtnetlink v0.0.0-20190830100107-3784a6c7c552/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= +github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jsternberg/zap-logfmt v1.0.0/go.mod h1:uvPs/4X51zdkcm5jXl5SYoN+4RK21K8mysFmDaM/h+o= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F6iIOGgxJ5npU/IUOhOhqlVrGjyIZc8/MagT0= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kardianos/service v1.0.0/go.mod h1:8CzDhVuCuugtsHyZoTvsOBuvonN/UDBvl0kH+BUxvbo= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= +github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= +github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= +github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= +github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.7 h1:0hzRabrMN4tSTvMfnL3SCv1ZGeAP23ynzodBgaHeMeg= +github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= +github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= +github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.0.0/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kshvakov/clickhouse v1.3.5/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLgx3MUnGwJqpE= +github.com/kubernetes/apimachinery v0.0.0-20190119020841-d41becfba9ee/go.mod h1:Pe/YBTPc3vqoMkbuIWPH8CF9ehINdvNyS0dP3J6HC0s= +github.com/kylelemons/godebug v0.0.0-20160406211939-eadb3ce320cb/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leanovate/gopter v0.2.4/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8= +github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= +github.com/leodido/ragel-machinery v0.0.0-20181214104525-299bdde78165/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg= +github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= +github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.0/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/linkedin/goavro/v2 v2.10.0 h1:eTBIRoInBM88gITGXYtUSqqxLTFXfOsJBiX8ZMW0o4U= +github.com/linkedin/goavro/v2 v2.10.0/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= +github.com/lovoo/gcloud-opentracing v0.3.0/go.mod h1:ZFqk2y38kMDDikZPAK7ynTTGuyt17nSPdS3K5e+ZTBY= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= +github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20180717111219-efc7eb8984d6/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= +github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e h1:qqXczln0qwkVGcpQ+sQuPOVntt2FytYarXXxYSNJkgw= +github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/mattetti/filebuffer v1.0.0/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-ieproxy v0.0.0-20191113090002-7c0f6868bffe/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA= +github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-tty v0.0.0-20180907095812-13ff1204f104/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= +github.com/mattn/go-xmlrpc v0.0.3/go.mod h1:mqc2dz7tP5x5BKlCahN/n+hs7OSZKJkS9JsHNBRlrxA= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/matttproud/golang_protobuf_extensions v1.0.0/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mdlayher/apcupsd v0.0.0-20200608131503-2bf01da7bf1b/go.mod h1:WYK/Z/aXq9cbMFIL5ihcA4sX/r/3/WCas/Qvs/2fXcA= +github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc= +github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= +github.com/mdlayher/netlink v0.0.0-20190828143259-340058475d09/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= +github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= +github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= +github.com/mdlayher/wifi v0.0.0-20190303161829-b1436901ddee/go.mod h1:Evt/EIne46u9PtQbeTx2NTcqURpr5K4SvKtGmBuDPN8= +github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.15/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.22/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.35/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.38/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/minio/md5-simd v1.1.0/go.mod h1:XpBqgZULrMYD3R+M28PcmP0CkI7PEMzB3U77ZrKZ0Gw= +github.com/minio/minio-go/v6 v6.0.44/go.mod h1:qD0lajrGW49lKZLtXKtCB4X/qkMf0a5tBvN2PaZg7Gg= +github.com/minio/minio-go/v6 v6.0.56/go.mod h1:KQMM+/44DSlSGSQWSfRrAZ12FVMmpWNuX37i2AX0jfI= +github.com/minio/minio-go/v7 v7.0.2/go.mod h1:dJ80Mv2HeGkYLH1sqS/ksz07ON6csH3S6JUMSQ2zAns= +github.com/minio/minio-go/v7 v7.0.10/go.mod h1:td4gW1ldOsj1PbSNS+WYK43j+P1XVhX/8W8awaYlBFo= +github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= +github.com/mna/redisc v1.1.7 h1:FdmtJsfTjoIjNXiQf4ozgNjuE+zxWH+fJSe+I/dD4vc= +github.com/mna/redisc v1.1.7/go.mod h1:GXeOb7zyYKiT+K8MKdIiJvuv7MfhDoQGcuzfiJQmqQI= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/mozilla/tls-observatory v0.0.0-20190404164649-a3c1b6cfecfd/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk= +github.com/mozillazg/go-cos v0.13.0/go.mod h1:Zp6DvvXn0RUOXGJ2chmWt2bLEqRAnJnS3DnAZsJsoaE= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto= +github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= +github.com/multiplay/go-ts3 v1.0.0/go.mod h1:14S6cS3fLNT3xOytrA/DkRyAFNuQLMLEqOYAsf87IbQ= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats-server/v2 v2.1.4/go.mod h1:Jw1Z28soD/QasIA2uWjXyM9El1jly3YwyFOuR8tH1rg= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= +github.com/ncw/swift v1.0.50/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= +github.com/ncw/swift v1.0.52/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= +github.com/newrelic/newrelic-telemetry-sdk-go v0.2.0/go.mod h1:G9MqE/cHGv3Hx3qpYhfuyFUsGx2DpVcGi1iJIqTg+JQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nsqio/go-nsq v1.0.7/go.mod h1:XP5zaUs3pqf+Q71EqUJs3HYfBIqfK6G83WQMdNN+Ito= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/ulid v0.0.0-20170117200651-66bb6560562f/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/olivere/elastic v6.2.35+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4= +github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs= +github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/openconfig/gnmi v0.0.0-20180912164834-33a1865c3029/go.mod h1:t+O9It+LKzfOAhKTT5O0ehDix+MTqbtT0T9t+7zzOvc= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opentracing-contrib/go-grpc v0.0.0-20180928155321-4b5a12d3ff02/go.mod h1:JNdpVEzCpXBgIiv4ds+TzhN1hrtxq6ClLrTlT9OQRSc= +github.com/opentracing-contrib/go-grpc v0.0.0-20191001143057-db30781987df/go.mod h1:DYR5Eij8rJl8h7gblRrOZ8g0kW1umSpKqYIBTgeDtLo= +github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e h1:4cPxUYdgaGzZIT5/j0IfqOrrXmq6bG8AwvwisMXpdrg= +github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e/go.mod h1:DYR5Eij8rJl8h7gblRrOZ8g0kW1umSpKqYIBTgeDtLo= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing-contrib/go-stdlib v0.0.0-20190519235532-cf7a6c988dc9/go.mod h1:PLldrQSroqzH70Xl+1DQcGnefIbqsKR7UDaiux3zV+w= +github.com/opentracing-contrib/go-stdlib v1.0.0 h1:TBS7YuVotp8myLon4Pv7BtCBzOTo1DeZCld0Z63mW2w= +github.com/opentracing-contrib/go-stdlib v1.0.0/go.mod h1:qtI1ogk+2JhVPIXVc6q+NHziSmy2W5GbdQZFUHADCBU= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.0.3-0.20180606204148-bd9c31933947/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.1-0.20200124165624-2876d2018785/go.mod h1:C+iumr2ni468+1jvcHXLCdqP9uQnoQbdX93F3aWahWU= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= +github.com/openzipkin/zipkin-go-opentracing v0.3.4/go.mod h1:js2AbwmHW0YD9DwIw2JhQWmbfFi/UnWyYwdVhqbCDOE= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/paulbellamy/ratecounter v0.2.0/go.mod h1:Hfx1hDpSGoqxkVVpBi/IlYD7kChlfo5C6hzIHwPqfFE= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= +github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pierrec/lz4 v0.0.0-20190327172049-315a67e90e41/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4 v2.4.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4 v2.6.0+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.1/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pkg/term v0.0.0-20180730021639-bffc007b7fd5/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pquerna/cachecontrol v0.0.0-20201205024021-ac21108117ac/go.mod h1:hoLfEwdY11HjRfKFH6KqnPsfxlo3BP6bJehpDv8t6sQ= +github.com/prometheus/alertmanager v0.18.0/go.mod h1:WcxHBl40VSPuOaqWae6l6HpnEOVRIycEJ7i9iYkadEE= +github.com/prometheus/alertmanager v0.19.0/go.mod h1:Eyp94Yi/T+kdeb2qvq66E3RGuph5T/jm/RBVh4yz1xo= +github.com/prometheus/alertmanager v0.20.0/go.mod h1:9g2i48FAyZW6BtbsnvHtMHQXl2aVtrORKwKVCQ+nbrg= +github.com/prometheus/alertmanager v0.21.0/go.mod h1:h7tJ81NA0VLWvWEayi1QltevFkLF3KxmC/malTcT8Go= +github.com/prometheus/alertmanager v0.21.1-0.20200911160112-1fdff6b3f939/go.mod h1:imXRHOP6QTsE0fFsIsAV/cXimS32m7gVZOiUj11m6Ig= +github.com/prometheus/alertmanager v0.21.1-0.20201106142418-c39b78780054/go.mod h1:imXRHOP6QTsE0fFsIsAV/cXimS32m7gVZOiUj11m6Ig= +github.com/prometheus/alertmanager v0.21.1-0.20210310093010-0f9cab6991e6/go.mod h1:MTqVn+vIupE0dzdgo+sMcNCp37SCAi8vPrvKTTnTz9g= +github.com/prometheus/alertmanager v0.21.1-0.20210422101724-8176f78a70e1/go.mod h1:gsEqwD5BHHW9RNKvCuPOrrTMiP5I+faJUyLXvnivHik= +github.com/prometheus/alertmanager v0.21.1-0.20210511232218-7301451eb94d h1:4b0yyecYCQ7Q64vtcq0Kmny7fiomEVEkC6x57zlzy3w= +github.com/prometheus/alertmanager v0.21.1-0.20210511232218-7301451eb94d/go.mod h1:VTGfCWWNKkw9ENSnUYhFcdtr3AwDsxBgB4rlB3kRJ4s= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.2.0/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.4.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.8.0/go.mod h1:O9VU6huf47PktckDQfMTX0Y8tY0/7TSWwj+ITvv0TnM= +github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU= +github.com/prometheus/client_golang v1.10.0 h1:/o0BDeWzLWXNZ+4q5gXltUvaMpJqckTa+jTNoB+z4cg= +github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU= +github.com/prometheus/client_model v0.0.0-20170216185247-6f3806018612/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20180518154759-7600349dcfe1/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.8.0/go.mod h1:PC/OgXc+UN7B4ALwvn1yzVZmVwvhXp5JsbBv6wSv6i0= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.11.1/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.12.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.14.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.20.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.21.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.23.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q= +github.com/prometheus/common v0.24.0 h1:aIycr3wRFxPUq8XlLQlGQ9aNXV3dFi5y62pe/SB262k= +github.com/prometheus/common v0.24.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q= +github.com/prometheus/exporter-toolkit v0.5.0/go.mod h1:OCkM4805mmisBhLmVFw858QYi3v0wKdY6/UxrT0pZVg= +github.com/prometheus/exporter-toolkit v0.5.1/go.mod h1:OCkM4805mmisBhLmVFw858QYi3v0wKdY6/UxrT0pZVg= +github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289 h1:dTUS1vaLWq+Y6XKOTnrFpoVsQKLCbCp1OLj24TDi7oM= +github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289/go.mod h1:FGbBv5OPKjch+jNUJmEQpMZytIdyW0NdBtWFcfSKusc= +github.com/prometheus/procfs v0.0.0-20180612222113-7d6f385de8be/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.6/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/prometheus v0.0.0-20180315085919-58e2a31db8de/go.mod h1:oAIUtOny2rjMX0OWN5vPR5/q/twIROJvdqnQKDdil/s= +github.com/prometheus/prometheus v0.0.0-20190818123050-43acd0e2e93f/go.mod h1:rMTlmxGCvukf2KMu3fClMDKLLoJ5hl61MhcJ7xKakf0= +github.com/prometheus/prometheus v1.8.2-0.20200107122003-4708915ac6ef/go.mod h1:7U90zPoLkWjEIQcy/rweQla82OCTUzxVHE51G3OhJbI= +github.com/prometheus/prometheus v1.8.2-0.20200213233353-b90be6f32a33/go.mod h1:fkIPPkuZnkXyopYHmXPxf9rgiPkVgZCN8w9o8+UgBlY= +github.com/prometheus/prometheus v1.8.2-0.20200707115909-30505a202a4c/go.mod h1:/kMSPIRsxr/apyHxlzYMdFnaPXUXXqILU5uzIoNhOvc= +github.com/prometheus/prometheus v1.8.2-0.20200722151933-4a8531a64b32/go.mod h1:+/y4DzJ62qmhy0o/H4PtXegRXw+80E8RVRHhLbv+bkM= +github.com/prometheus/prometheus v1.8.2-0.20200805082714-e0cf219f0de2/go.mod h1:i1KZsZmyDTJRvnR7zE8z/u2v+tkpPjoiPpnWp6nwhr0= +github.com/prometheus/prometheus v1.8.2-0.20200819132913-cb830b0a9c78/go.mod h1:zfAqy/MwhMFajB9E2n12/9gG2fvofIE9uKDtlZCDxqs= +github.com/prometheus/prometheus v1.8.2-0.20200923143134-7e2db3d092f3/go.mod h1:9VNWoDFHOMovlubld5uKKxfCDcPBj2GMOCjcUFXkYaM= +github.com/prometheus/prometheus v1.8.2-0.20201028100903-3245b3267b24/go.mod h1:MDRkz271loM/PrYN+wUNEaTMDGSP760MQzB0yEjdgSQ= +github.com/prometheus/prometheus v1.8.2-0.20201029103703-63be30dceed9/go.mod h1:MDRkz271loM/PrYN+wUNEaTMDGSP760MQzB0yEjdgSQ= +github.com/prometheus/prometheus v1.8.2-0.20201119142752-3ad25a6dc3d9/go.mod h1:1MDE/bXgu4gqd5w/otko6WQpXZX9vu8QX4KbitCmaPg= +github.com/prometheus/prometheus v1.8.2-0.20201119181812-c8f810083d3f/go.mod h1:1MDE/bXgu4gqd5w/otko6WQpXZX9vu8QX4KbitCmaPg= +github.com/prometheus/prometheus v1.8.2-0.20210215121130-6f488061dfb4/go.mod h1:NAYujktP0dmSSpeV155mtnwX2pndLpVVK/Ps68R01TA= +github.com/prometheus/prometheus v1.8.2-0.20210217141258-a6be548dbc17/go.mod h1:dv3B1syqmkrkmo665MPCU6L8PbTXIiUeg/OEQULLNxA= +github.com/prometheus/prometheus v1.8.2-0.20210315220929-1cba1741828b/go.mod h1:MS/bpdil77lPbfQeKk6OqVQ9OLnpN3Rszd0hka0EOWE= +github.com/prometheus/prometheus v1.8.2-0.20210324152458-c7a62b95cea0/go.mod h1:sf7j/iAbhZahjeC0s3wwMmp5dksrJ/Za1UKdR+j6Hmw= +github.com/prometheus/prometheus v1.8.2-0.20210421143221-52df5ef7a3be h1:Kt84gUEhCC04CEqQxML1W+wzpydx1t3rmaEq1H971ZM= +github.com/prometheus/prometheus v1.8.2-0.20210421143221-52df5ef7a3be/go.mod h1:WbIKsp4vWCoPHis5qQfd0QimLOR7qe79roXN5O8U8bs= +github.com/prometheus/statsd_exporter v0.20.0/go.mod h1:YL3FWCG8JBBtaUSxAg4Gz2ZYu22bS84XM89ZQXXTWmQ= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rafaeljusto/redigomock v0.0.0-20190202135759-257e089e14a1/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc= +github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/russellhaering/goxmldsig v1.1.0 h1:lK/zeJie2sqG52ZAlPNn1oBBqsIsEKypUUBGpYYF6lk= +github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/safchain/ethtool v0.0.0-20200218184317-f459e2d13664/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= +github.com/samuel/go-zookeeper v0.0.0-20180130194729-c4fab1ac1bec/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/samuel/go-zookeeper v0.0.0-20200724154423-2164a8ac840e/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4= +github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= +github.com/satori/go.uuid v0.0.0-20160603004225-b111a074d5ef/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210223165440-c65ae3540d44/go.mod h1:CJJ5VAbozOl0yEw7nHB9+7BXTJbIn6h7W+f6Gau5IP8= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/securego/gosec v0.0.0-20200203094520-d13bb6d2420c/go.mod h1:gp0gaHj0WlmPh9BdsTmo1aq6C27yIPWdxCKGFGdVKBE= +github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e/go.mod h1:tm/wZFQ8e24NYaBGIlnO2WGCAi67re4HHuOm0sftE/M= +github.com/segmentio/fasthash v1.0.2/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= +github.com/segmentio/kafka-go v0.1.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= +github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= +github.com/sercand/kuberesolver v2.1.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ= +github.com/sercand/kuberesolver v2.4.0+incompatible h1:WE2OlRf6wjLxHwNkkFLQGaZcVLEXjMjBPjjEU5vksH8= +github.com/sercand/kuberesolver v2.4.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil v2.20.9+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shirou/gopsutil v3.21.3+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk= +github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/vfsgen v0.0.0-20180825020608-02ddb050ef6b/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/shurcooL/vfsgen v0.0.0-20200627165143-92b8a710ab6c/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 h1:pXY9qYc/MP5zdvqWEUH6SjNiu7VhSjuVFTFiTcphaLU= +github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/siebenmann/go-kstat v0.0.0-20160321171754-d34789b79745/go.mod h1:G81aIFAMS9ECrwBYR9YxhlPjWgrItd+Kje78O6+uqm8= +github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= +github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/soheilhy/cmux v0.1.5-0.20210205191134-5ec6847320e5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/soniah/gosnmp v1.25.0/go.mod h1:8YvfZxH388NIIw2A+X5z2Oh97VcNhtmxDLt5QeUzVuQ= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/soundcloud/go-runit v0.0.0-20150630195641-06ad41a06c4a/go.mod h1:LeFCbQYJ3KJlPs/FvPz2dy1tkpxyeNESVyCNNzRXFR0= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.6.2/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/streadway/amqp v0.0.0-20180528204448-e5adc2ada8b8/go.mod h1:1WNBiOZtZQLpVAyu0iTduoJL9hEsMloAK5XWrtW0xdY= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tbrandon/mbserver v0.0.0-20170611213546-993e1772cc62/go.mod h1:qUzPVlSj2UgxJkVbH0ZwuuiR46U8RBMDT5KLY78Ifpw= +github.com/tedsuo/ifrit v0.0.0-20191009134036-9a97d0632f00/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= +github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= +github.com/thanos-io/thanos v0.8.1-0.20200109203923-552ffa4c1a0d/go.mod h1:usT/TxtJQ7DzinTt+G9kinDQmRS5sxwu0unVKZ9vdcw= +github.com/thanos-io/thanos v0.13.1-0.20200731083140-69b87607decf/go.mod h1:G8caR6G7pSDreRDvFm9wFuyjEBztmr8Ag3kBYpa/fEc= +github.com/thanos-io/thanos v0.13.1-0.20200807203500-9b578afb4763/go.mod h1:KyW0a93tsh7v4hXAwo2CVAIRYuZT1Kkf4e04gisQjAg= +github.com/thanos-io/thanos v0.13.1-0.20201019130456-f41940581d9a/go.mod h1:A3qUEEbsVkplJnxyDLwuIuvTDaJPByTH+hMdTl9ujAA= +github.com/thanos-io/thanos v0.13.1-0.20201030101306-47f9a225cc52/go.mod h1:OqqX4x21cg5N5MMHd/yGQAc/V3wg8a7Do4Jk8HfaFZQ= +github.com/thanos-io/thanos v0.13.1-0.20210108102609-f85e4003ba51/go.mod h1:kPvI4H0AynFiHDN95ZB28/k70ZPGCx+pBrRh6RZPimw= +github.com/thanos-io/thanos v0.13.1-0.20210204123931-82545cdd16fe/go.mod h1:ZLDGYRNkgM+FCwYNOD+6tOV+DE2fpjzfV6iqXyOgFIw= +github.com/thanos-io/thanos v0.13.1-0.20210224074000-659446cab117/go.mod h1:kdqFpzdkveIKpNNECVJd75RPvgsAifQgJymwCdfev1w= +github.com/thanos-io/thanos v0.13.1-0.20210226164558-03dace0a1aa1/go.mod h1:gMCy4oCteKTT7VuXVvXLTPGzzjovX1VPE5p+HgL1hyU= +github.com/thanos-io/thanos v0.13.1-0.20210401085038-d7dff0c84d17/go.mod h1:zU8KqE+6A+HksK4wiep8e/3UvCZLm+Wrw9AqZGaAm9k= +github.com/thanos-io/thanos v0.19.1-0.20210427154226-d5bd651319d2/go.mod h1:zvSf4uKtey4KjSVcalV/5oUuGthaTzI8kVDrO42I8II= +github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/timberio/go-datemath v0.1.1-0.20200323150745-74ddef604fff h1:QCdUBuN+iKWAB9HqPTkBwyKPPUHDobJ2AuELSNZwd4o= +github.com/timberio/go-datemath v0.1.1-0.20200323150745-74ddef604fff/go.mod h1:m7kjsbCuO4QKP3KLfnxiUZWiOiFXmxj30HeexjL3lc0= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tklauser/go-sysconf v0.3.4/go.mod h1:Cl2c8ZRWfHD5IrfHo9VN+FX9kCFjIOyVklgXycLB6ek= +github.com/tklauser/numcpus v0.2.1/go.mod h1:9aU+wOc6WjUIZEwWMP62PL/41d65P+iks1gBkr4QyP8= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20200427203606-3cfed13b9966/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tonistiigi/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:Q5IRRDY+cjIaiOjTAnXN5LKQV5MPqVx5ofQn85Jy5Yw= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329 h1:VBsKFh4W1JEMz3eLCmM9zOJKZdDkP5W4b3Y4hc7SbZc= +github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E= +github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.20.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.22.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.23.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.24.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.25.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.27.0+incompatible h1:6WVONolFJiB8Vx9bq4z9ddyV/SXSpfvvtb7Yl/TGHiE= +github.com/uber/jaeger-client-go v2.27.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v1.5.1-0.20181102163054-1fc5c315e03c/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/uber/jaeger-lib v2.2.0+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/uber/jaeger-lib v2.4.0+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= +github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= +github.com/vektra/mockery v0.0.0-20181123154057-e78b021dcbb5/go.mod h1:ppEjwdhyy7Y31EnHRDm1JkChoC7LXIJ7Ex0VYLWtZtQ= +github.com/vishvananda/netlink v0.0.0-20171020171820-b2de5d10e38e/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI= +github.com/vjeantet/grok v1.0.0/go.mod h1:/FWYEVYekkm+2VjcFmO9PufDU5FgXHUz9oy2EGqmQBo= +github.com/vmware/govmomi v0.19.0/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU= +github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad/go.mod h1:Hy8o65+MXnS6EwGElrSRjUzQDLXreJlzYLlWiHtt8hM= +github.com/wavefronthq/wavefront-sdk-go v0.9.2/go.mod h1:hQI6y8M9OtTCtc0xdwh+dCER4osxXdEAeCpacjpDZEU= +github.com/weaveworks/common v0.0.0-20200206153930-760e36ae819a/go.mod h1:6enWAqfQBFrE8X/XdJwZr8IKgh1chStuFR0mjU/UOUw= +github.com/weaveworks/common v0.0.0-20200625145055-4b1847531bc9/go.mod h1:c98fKi5B9u8OsKGiWHLRKus6ToQ1Tubeow44ECO1uxY= +github.com/weaveworks/common v0.0.0-20200914083218-61ffdd448099/go.mod h1:hz10LOsAdzC3K/iXaKoFxOKTDRgxJl+BTGX1GY+TzO4= +github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec/go.mod h1:ykzWac1LtVfOxdCK+jD754at1Ws9dKCwFeUzkFBffPs= +github.com/weaveworks/common v0.0.0-20210112142934-23c8d7fa6120/go.mod h1:ykzWac1LtVfOxdCK+jD754at1Ws9dKCwFeUzkFBffPs= +github.com/weaveworks/common v0.0.0-20210419092856-009d1eebd624 h1:rbPhNKTbWNWchMqGWKKVYUocxiAk1ii5b8D/C49v/Lg= +github.com/weaveworks/common v0.0.0-20210419092856-009d1eebd624/go.mod h1:ykzWac1LtVfOxdCK+jD754at1Ws9dKCwFeUzkFBffPs= +github.com/weaveworks/promrus v1.2.0 h1:jOLf6pe6/vss4qGHjXmGz4oDJQA+AOCqEL3FvvZGz7M= +github.com/weaveworks/promrus v1.2.0/go.mod h1:SaE82+OJ91yqjrE1rsvBWVzNZKcHYFtMUyS1+Ogs/KA= +github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/wvanbergen/kafka v0.0.0-20171203153745-e2edea948ddf/go.mod h1:nxx7XRXbR9ykhnC8lXqQyJS0rfvJGxKyKw/sT1YOttg= +github.com/wvanbergen/kazoo-go v0.0.0-20180202103751-f72d8611297a/go.mod h1:vQQATAGxVK20DC1rRubTJbZDDhhpA4QfU02pMdPxGO4= +github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:FV1RpvYFmF8wnKtr3ArzkC0b+tAySCbw8eP7QSIvLKM= +github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= +github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v0.0.0-20180616005107-d6fb6747feb6/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= +github.com/xlab/treeprint v1.0.0/go.mod h1:IoImgRak9i3zJyuxOKUP1v4UZd1tMoKkq/Cimt1uhCg= +github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xorcare/pointer v1.1.0 h1:sFwXOhRF8QZ0tyVZrtxWGIoVZNEmRzBCaFWdONPQIUM= +github.com/xorcare/pointer v1.1.0/go.mod h1:6KLhkOh6YbuvZkT4YbxIbR/wzLBjyMxOiNzZhJTor2Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/gopher-lua v0.0.0-20180630135845-46796da1b0b4/go.mod h1:aEV29XrmTYFr3CiRxZeGHpkvbwq+prZduBqMaascyCU= +github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA= +github.com/zenazn/goji v0.9.1-0.20160507202103-64eb34159fe5/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= +go.elastic.co/apm v1.5.0/go.mod h1:OdB9sPtM6Vt7oz3VXt7+KR96i9li74qrxBGHTQygFvk= +go.elastic.co/apm v1.11.0/go.mod h1:qoOSi09pnzJDh5fKnfY7bPmQgl8yl2tULdOu03xhui0= +go.elastic.co/apm/module/apmhttp v1.5.0/go.mod h1:1FbmNuyD3ddauwzgVwFB0fqY6KbZt3JkV187tGCYYhY= +go.elastic.co/apm/module/apmhttp v1.11.0/go.mod h1:5JFMIxdeS4vJy+D1PPPjINuX6hZ3AHalZXoOgyqZAkk= +go.elastic.co/apm/module/apmot v1.5.0/go.mod h1:d2KYwhJParTpyw2WnTNy8geNlHKKFX+4oK3YLlsesWE= +go.elastic.co/apm/module/apmot v1.11.0/go.mod h1:Qnbt3w1DvUd/5QugAF1AJ3mR4AG86EcJFBnAGW77EmU= +go.elastic.co/fastjson v1.0.0/go.mod h1:PmeUOMMtLHQr9ZS9J9owrAVg0FkaZDRZJEFTTGHtchs= +go.elastic.co/fastjson v1.1.0/go.mod h1:boNGISWMjQsUPy/t6yqt2/1Wx4YNPSe+mZjlyw9vKKI= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/etcd v0.0.0-20190709142735-eb7dd97135a5/go.mod h1:N0RPWo9FXJYZQI4BTkDtQylrstIigYHeR18ONnyTufk= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.etcd.io/etcd v0.5.0-alpha.5.0.20200520232829-54ba9589114f/go.mod h1:skWido08r9w6Lq/w70DO5XYIKMu4QFu1+4VsqLQuJy8= +go.etcd.io/etcd/api/v3 v3.5.0-alpha.0/go.mod h1:mPcW6aZJukV6Aa81LSKpBjQXTWlXB5r74ymPoSWa3Sw= +go.etcd.io/etcd/client/v2 v2.305.0-alpha.0/go.mod h1:kdV+xzCJ3luEBSIeQyB/OEKkWKd8Zkux4sbDeANrosU= +go.etcd.io/etcd/client/v3 v3.5.0-alpha.0/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8= +go.etcd.io/etcd/client/v3 v3.5.0-alpha.0.0.20210225194612-fa82d11a958a/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8= +go.etcd.io/etcd/pkg/v3 v3.5.0-alpha.0/go.mod h1:tV31atvwzcybuqejDoY3oaNRTtlD2l/Ot78Pc9w7DMY= +go.etcd.io/etcd/raft/v3 v3.5.0-alpha.0/go.mod h1:FAwse6Zlm5v4tEWZaTjmNhe17Int4Oxbu7+2r0DiD3w= +go.etcd.io/etcd/server/v3 v3.5.0-alpha.0.0.20210225194612-fa82d11a958a/go.mod h1:tsKetYpt980ZTpzl/gb+UOJj9RkIyCb1u4wjzMg90BQ= +go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.0.4/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.3.0/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.mongodb.org/mongo-driver v1.3.2/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.mongodb.org/mongo-driver v1.4.3/go.mod h1:WcMNYLx/IlOxLe6JRJiv2uXuCz6zBLndR4SoGjYphSc= +go.mongodb.org/mongo-driver v1.4.4/go.mod h1:WcMNYLx/IlOxLe6JRJiv2uXuCz6zBLndR4SoGjYphSc= +go.mongodb.org/mongo-driver v1.4.6/go.mod h1:WcMNYLx/IlOxLe6JRJiv2uXuCz6zBLndR4SoGjYphSc= +go.mongodb.org/mongo-driver v1.5.1 h1:9nOVLGDfOaZ9R0tBumx/BcuqkbFpyTCU2r/Po7A2azI= +go.mongodb.org/mongo-driver v1.5.1/go.mod h1:gRXCHX4Jo7J0IJ1oDQyUxF7jfy19UfxniMS4xxMmUqw= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/collector v0.25.0 h1:CVrqPgr0Kr/Se1ihS6jam1/n4hndXk3GHHAOsruSDzw= +go.opentelemetry.io/collector v0.25.0/go.mod h1:hXpdip0pVo+lISHAzPtu13QIRHgqC1zZ/4EdgoEC0fc= +go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0= +go.starlark.net v0.0.0-20200901195727-6e684ef5eeee/go.mod h1:f0znQkUKRrkk36XxWbGjMqQM8wGv/xHBVE2qc3B5oFU= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/automaxprocs v1.2.0/go.mod h1:YfO3fm683kQpzETxlTGZhGIVmXAhaw3gxeBADbpZtnU= +go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q= +go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.14.1/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +golang.org/x/crypto v0.0.0-20180608092829-8ac0e0d97ce4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191112222119-e1110fd1c708/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= +golang.org/x/exp v0.0.0-20191029154019-8994fa331a53/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= +golang.org/x/exp v0.0.0-20200821190819-94841d0725da/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20210126221216-84987778548c/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/exp v0.0.0-20210220032938-85be41e4509f h1:GrkO5AtFUU9U/1f5ctbIBXtBGeSJbWwIYfIsTcFMaX4= +golang.org/x/exp v0.0.0-20210220032938-85be41e4509f/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210216034530-4410531fe030/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190424112056-4829fb13d2c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210224082022-3d97a244fca7/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210324051636-2c4c8ecb7826/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758 h1:aEpZnXcAmXkd6AvLb2OPt+EN1Zu/8Ne3pCqPjja5PXY= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210210192628-66670185b0cd/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78 h1:rPRtHfUb0UKZeZ6GH4K4Nt4YRbE9V1u+QZX5upZXqJQ= +golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200930132711-30421366ff76/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190102155601-82a175fd1598/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190425145619-16072639606e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190426135247-a129542de9ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190902133755-9109b7679e13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191003212358-c178f38b412c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200107162124-548cf772de50/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210217105451-b926d437f341/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210314195730-07df6a141424/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe h1:WdX7u8s3yOigWAhHEaDl8r9G+4XwFQEQFtBMYyN+kXQ= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180805044716-cb6730876b98/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181112210238-4b1f3b6b1646/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190118193359-16909d206f00/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190802220118-1d1727260058/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190805222050-c5a2fd39b72a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190813034749-528a2984e271/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190918214516-5a1a30219888/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191111182352-50fa39b762bc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191203134012-c197fd4bf371/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200108203644-89082a384178/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200203023011-6f24f261dadb/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200216192241-b320d3a0f5a2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200317043434-63da46f3035e/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200513201620-d5fe73897c97/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200603131246-cc40288be839/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200612220849-54c614fe050c/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200725200936-102e7d357031/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200822203824-307de81be3f4/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201014170642-d1624618ad65/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201020161133-226fd2f889ca/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201119054027-25dc3e1ccc3c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201226215659-b1c90890d22a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wireguard v0.0.20200121/go.mod h1:P2HsVp8SKwZEufsnezXZA4GRX/T49/HlU7DGuelXsU4= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200205215550-e35592f146e4/go.mod h1:UdS9frhv65KTfwxME1xE8+rHYoFpbm36gOud1GhBe9c= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.6.0/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU= +gonum.org/v1/gonum v0.6.2/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU= +gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/gonum v0.9.1 h1:HCWmqqNoELL0RAQeKBXWtkp04mGk8koafcB4He6+uhc= +gonum.org/v1/gonum v0.9.1/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= +gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.3.2/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.26.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.32.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.39.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.42.0/go.mod h1:+Oj4s6ch2SEGtPjGqfUfZonBH0GjQH89gTeKKAEGZKI= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.45.0 h1:pqMffJFLBVUDIoYsHcqtxgQVTsmxMDpYLOc5MT4Jrww= +google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180608181217-32ee49c4dd80/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190716160619-c506a9f90610/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20190927181202-20e1ac93f88c/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191028173616-919d9bdd9fe6/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200108215221-bd8f9a0ef82f/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200317114155-1f3552e48f24/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200603110839-e855014d5736/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200710124503-20a17af7bd0e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200724131911-43cab4749ae7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200911024640-645f7a48b24f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210302174412-5ede27ff9881/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210312152112-fc591d9ea70f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3 h1:K+7Ig5hjiLVA/i1UFUUbCGimWz5/Ey0lAQjT3QiLaPY= +google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0 h1:uSZWeQJX5j11bIQ4AJoj+McDBo29cY1MCoC1wO3ts+c= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v0.0.0-20200910201057-6591123024b3/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY= +gopkg.in/fsnotify.v1 v1.2.1/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/gorethink/gorethink.v3 v3.0.5/go.mod h1:+3yIIHJUGMBK+wyPH+iN5TP+88ikFDfZdqTlK3Y9q8I= +gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.46.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.52.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/jcmturner/aescts.v1 v1.0.1/go.mod h1:nsR8qBOg+OucoIW+WMhB3GspUQXq9XorLnQb9XtvcOo= +gopkg.in/jcmturner/dnsutils.v1 v1.0.1/go.mod h1:m3v+5svpVOhtFAP/wSz+yzh4Mc0Fg7eRhxkJMWSIz9Q= +gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4= +gopkg.in/jcmturner/gokrb5.v7 v7.2.3/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuvyavf11/WM= +gopkg.in/jcmturner/gokrb5.v7 v7.5.0/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuvyavf11/WM= +gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8= +gopkg.in/ldap.v3 v3.1.0 h1:DIDWEjI7vQWREh0S8X5/NFPCZ3MCVd55LmXKPW4XLGE= +gopkg.in/ldap.v3 v3.1.0/go.mod h1:dQjCc0R0kfyFjIlWNMH1DORwUASZyDxo2Ry1B51dXaQ= +gopkg.in/macaron.v1 v1.3.4/go.mod h1:/RoHTdC8ALpyJ3+QR36mKjwnT1F1dyYtsGM9Ate6ZFI= +gopkg.in/macaron.v1 v1.4.0 h1:RJHC09fAnQ8tuGUiZNjG0uyL1BWSdSWd9SpufIcEArQ= +gopkg.in/macaron.v1 v1.4.0/go.mod h1:uMZCFccv9yr5TipIalVOyAyZQuOH3OkmXvgcWwhJuP4= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/olivere/elastic.v5 v5.0.70/go.mod h1:FylZT6jQWtfHsicejzOm3jIMVPOAksa80i3o+6qtQRk= +gopkg.in/redis.v5 v5.2.9 h1:MNZYOLPomQzZMfpN3ZtD1uyJ2IDonTTlxYiV/pEApiw= +gopkg.in/redis.v5 v5.2.9/go.mod h1:6gtv0/+A4iM08kdRfocWYB3bLX2tebpNtfKlFT6H4mY= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20140529071818-c131134a1947/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +honnef.co/go/netdb v0.0.0-20150201073656-a416d700ae39/go.mod h1:rbNo0ST5hSazCG4rGfpHrwnwvzP1QX62WbhzD+ghGzs= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.1.1/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +k8s.io/api v0.0.0-20190813020757-36bff7324fb7/go.mod h1:3Iy+myeAORNCLgjd/Xu9ebwN7Vh59Bw0vh9jhoX+V58= +k8s.io/api v0.0.0-20191115095533-47f6de673b26/go.mod h1:iA/8arsvelvo4IDqIhX4IbjTEKBGgvsf2OraTuRtLFU= +k8s.io/api v0.18.3/go.mod h1:UOaMwERbqJMfeeeHc8XJKawj4P9TgDRnViIqqBeH2QA= +k8s.io/api v0.18.5/go.mod h1:tN+e/2nbdGKOAH55NMV8oGrMG+3uRlA9GaRfvnCCSNk= +k8s.io/api v0.18.6/go.mod h1:eeyxr+cwCjMdLAmr2W3RyDI0VvTawSg/3RFFBEnmZGI= +k8s.io/api v0.18.8/go.mod h1:d/CXqwWv+Z2XEG1LgceeDmHQwpUJhROPx16SlxJgERY= +k8s.io/api v0.19.2/go.mod h1:IQpK0zFQ1xc5iNIQPqzgoOwuFugaYHK4iCknlAQP9nI= +k8s.io/api v0.19.4/go.mod h1:SbtJ2aHCItirzdJ36YslycFNzWADYH3tgOhvBEFtZAk= +k8s.io/api v0.20.2/go.mod h1:d7n6Ehyzx+S+cE3VhTGfVNNqtGc/oL9DCdYYahlurV8= +k8s.io/api v0.20.5/go.mod h1:FQjAceXnVaWDeov2YUWhOb6Yt+5UjErkp6UO3nczO1Y= +k8s.io/api v0.21.0/go.mod h1:+YbrhBBGgsxbF6o6Kj4KJPJnBmAKuXDeS3E18bgHNVU= +k8s.io/apimachinery v0.0.0-20190809020650-423f5d784010/go.mod h1:Waf/xTS2FGRrgXCkO5FP3XxTOWh0qLf2QhL1qFZZ/R8= +k8s.io/apimachinery v0.0.0-20191115015347-3c7067801da2/go.mod h1:dXFS2zaQR8fyzuvRdJDHw2Aerij/yVGJSre0bZQSVJA= +k8s.io/apimachinery v0.17.1/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg= +k8s.io/apimachinery v0.18.3/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apimachinery v0.18.5/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apimachinery v0.18.6/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig= +k8s.io/apimachinery v0.19.2/go.mod h1:DnPGDnARWFvYa3pMHgSxtbZb7gpzzAZ1pTfaUNDVlmA= +k8s.io/apimachinery v0.19.4/go.mod h1:DnPGDnARWFvYa3pMHgSxtbZb7gpzzAZ1pTfaUNDVlmA= +k8s.io/apimachinery v0.20.2/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= +k8s.io/apimachinery v0.20.5/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= +k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY= +k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.1/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.3.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.5.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec= +k8s.io/klog/v2 v2.8.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec= +k8s.io/kube-openapi v0.0.0-20190709113604-33be087ad058/go.mod h1:nfDlWeOsu3pUf4yWGL+ERqohP4YsZcBJXWMK+gkzOA4= +k8s.io/kube-openapi v0.0.0-20190722073852-5e22f3d471e6/go.mod h1:RZvgC8MSN6DjiMV6oIfEE9pDL9CYXokkfaCKZeHm3nc= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= +k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= +k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= +k8s.io/kube-openapi v0.0.0-20210305001622-591a79e4bda7/go.mod h1:wXW5VT87nVfh/iLV8FpR2uDvrFyomxbtb1KivDbvPTE= +k8s.io/utils v0.0.0-20190809000727-6c36bc71fc4a/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20191114200735-6ca3b61696b6/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200414100711-2df71ebbae66/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +modernc.org/httpfs v1.0.0/go.mod h1:BSkfoMUcahSijQD5J/Vu4UMOxzmEf5SNRwyXC4PJBEw= +modernc.org/libc v1.3.1/go.mod h1:f8sp9GAfEyGYh3lsRIKtBh/XwACdFvGznxm6GJmQvXk= +modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.0.1/go.mod h1:NSjvC08+g3MLOpcAxQbdctcThAEX4YlJ20WWHYEhvRg= +modernc.org/sqlite v1.7.4/go.mod h1:xse4RHCm8Fzw0COf5SJqAyiDrVeDwAQthAS1V/woNIA= +modernc.org/tcl v1.4.1/go.mod h1:8YCvzidU9SIwkz7RZwlCWK61mhV8X9UwfkRDRp7y5e0= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= +sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= +sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.1.0/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= +xorm.io/builder v0.3.6 h1:ha28mQ2M+TFx96Hxo+iq6tQgnkC9IZkM6D8w9sKHHF8= +xorm.io/builder v0.3.6/go.mod h1:LEFAPISnRzG+zxaxj2vPicRwz67BdhFreKg8yv8/TgU= +xorm.io/core v0.7.2/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= +xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0= +xorm.io/core v0.7.3/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= +xorm.io/xorm v0.8.2 h1:nbg1AyWn7iLrwp0Dqg8IrYOBkBYYJ85ry9bvZLVl4Ok= +xorm.io/xorm v0.8.2/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY= diff --git a/grafana-mixin/.gitignore b/grafana-mixin/.gitignore new file mode 100644 index 0000000..56b93f9 --- /dev/null +++ b/grafana-mixin/.gitignore @@ -0,0 +1,3 @@ +/alerts.yaml +/rules.yaml +dashboards_out \ No newline at end of file diff --git a/grafana-mixin/Makefile b/grafana-mixin/Makefile new file mode 100644 index 0000000..df6ef32 --- /dev/null +++ b/grafana-mixin/Makefile @@ -0,0 +1,13 @@ +all: fmt lint build clean + +fmt: + ./scripts/format.sh + +lint: + ./scripts/lint.sh + +build: + ./scripts/build.sh + +clean: + rm -rf dashboards_out alerts.yaml rules.yaml diff --git a/grafana-mixin/README.md b/grafana-mixin/README.md new file mode 100644 index 0000000..60feb1d --- /dev/null +++ b/grafana-mixin/README.md @@ -0,0 +1,28 @@ +# Grafana Mixin + +_This is a work in progress. We aim for it to become a good role model for alerts +and dashboards eventually, but it is not quite there yet._ + +The Grafana Mixin is a set of configurable, reusable, and extensible alerts and +dashboards based on the metrics exported by Grafana. The mixin creates +recording and alerting rules for Prometheus and suitable dashboard descriptions +for Grafana. + +To use them, you need to have `mixtool` and `jsonnetfmt` installed. If you +have a working Go development environment, it's easiest to run the following: + +```bash +$ go get github.com/monitoring-mixins/mixtool/cmd/mixtool +$ go get github.com/google/go-jsonnet/cmd/jsonnetfmt +``` + +You can then build the Prometheus rules files `alerts.yaml` and +`rules.yaml` and a directory `dashboard_out` with the JSON dashboard files +for Grafana: + +```bash +$ make build +``` + +For more advanced uses of mixins, see +https://github.com/monitoring-mixins/docs. diff --git a/grafana-mixin/alerts/alerts.yaml b/grafana-mixin/alerts/alerts.yaml new file mode 100644 index 0000000..d81b11e --- /dev/null +++ b/grafana-mixin/alerts/alerts.yaml @@ -0,0 +1,14 @@ +groups: + - name: GrafanaAlerts + rules: + - alert: GrafanaRequestsFailing + for: 5m + expr: | + 100 * namespace_job_handler_statuscode:http_request_total:rate5m{handler!~"/datasources/proxy/:id.*|/ds/query|/tsdb/query", statuscode=~"5.."} + / + namespace_job_handler_statuscode:http_request_total:rate5m{handler!~"/datasources/proxy/:id.*|/ds/query|/tsdb/query"} + > 0.5 + labels: + severity: 'warning' + annotations: + message: "'{{ $labels.namespace }}' / '{{ $labels.job }}' / '{{ $labels.handler }}' is experiencing {{ $value | humanize }}% errors" diff --git a/grafana-mixin/dashboards/grafana-overview.json b/grafana-mixin/dashboards/grafana-overview.json new file mode 100644 index 0000000..de50454 --- /dev/null +++ b/grafana-mixin/dashboards/grafana-overview.json @@ -0,0 +1,512 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 35, + "iteration": 1602761142538, + "links": [], + "panels": [ + { + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + } + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "expr": "grafana_alerting_result_total{job=~\"$job\", instance=~\"$instance\", state=\"alerting\"}", + "instant": true, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Firing Alerts", + "type": "stat" + }, + { + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + } + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "expr": "sum(grafana_stat_totals_dashboard{job=~\"$job\", instance=~\"$instance\"})", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Dashboards", + "type": "stat" + }, + { + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": { + "align": null + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 10, + "options": { + "showHeader": true + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "expr": "grafana_build_info{job=~\"$job\", instance=~\"$instance\"}", + "instant": true, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Build Info", + "transformations": [ + { + "id": "labelsToFields", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Value": true, + "branch": true, + "container": true, + "goversion": true, + "namespace": true, + "pod": true, + "revision": true + }, + "indexByName": { + "Time": 7, + "Value": 11, + "branch": 4, + "container": 8, + "edition": 2, + "goversion": 6, + "instance": 1, + "job": 0, + "namespace": 9, + "pod": 10, + "revision": 5, + "version": 3 + }, + "renameByName": {} + } + } + ], + "type": "table" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (statuscode) (irate(http_request_total{job=~\"$job\", instance=~\"$instance\"}[1m])) ", + "interval": "", + "legendFormat": "{{statuscode}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "RPS", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:157", + "format": "reqps", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:158", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "max(http_request_duration_milliseconds{job=~\"$job\", instance=~\"$instance\", quantile=\"0.99\"})", + "interval": "", + "legendFormat": "max-99th", + "refId": "A" + }, + { + "expr": "max(http_request_duration_milliseconds{job=~\"$job\", instance=~\"$instance\", quantile=\"0.9\"})", + "interval": "", + "legendFormat": "max-90th", + "refId": "B" + }, + { + "expr": "sum(irate(http_request_duration_milliseconds_sum{job=~\"$job\", instance=~\"$instance\"}[$__interval])) / sum(irate(http_request_duration_milliseconds_count{job=~\"$job\", instance=~\"$instance\"}[$__interval])) ", + "interval": "", + "legendFormat": "avg", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Request Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:210", + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:211", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 25, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "prometheus", + "value": "prometheus" + }, + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "tags": [], + "text": "All", + "value": ["$__all"] + }, + "datasource": "$datasource", + "definition": "label_values(grafana_build_info, job)", + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "name": "job", + "options": [], + "query": "label_values(grafana_build_info, job)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": "$datasource", + "definition": "label_values(grafana_build_info, instance)", + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "name": "instance", + "options": [], + "query": "label_values(grafana_build_info, instance)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Grafana Overview", + "uid": "6be0s85Mk", + "version": 4 +} diff --git a/grafana-mixin/mixin.libsonnet b/grafana-mixin/mixin.libsonnet new file mode 100644 index 0000000..c60e0e7 --- /dev/null +++ b/grafana-mixin/mixin.libsonnet @@ -0,0 +1,15 @@ +{ + grafanaDashboards: { + 'grafana-overview.json': (import 'dashboards/grafana-overview.json'), + }, + + // Helper function to ensure that we don't override other rules, by forcing + // the patching of the groups list, and not the overall rules object. + local importRules(rules) = { + groups+: std.native('parseYaml')(rules)[0].groups, + }, + + prometheusRules+: importRules(importstr 'rules/rules.yaml'), + + prometheusAlerts+: importRules(importstr 'alerts/alerts.yaml'), +} diff --git a/grafana-mixin/rules/rules.yaml b/grafana-mixin/rules/rules.yaml new file mode 100644 index 0000000..9eab65e --- /dev/null +++ b/grafana-mixin/rules/rules.yaml @@ -0,0 +1,7 @@ +groups: + - name: grafana_rules + rules: + # Record error rate of http requests excluding dataproxy, /ds/query and /tsdb/query requests + - record: namespace_job_handler_statuscode:http_request_total:rate5m + expr: | + sum by (namespace, job, handler, statuscode) (rate(http_request_total[5m])) diff --git a/grafana-mixin/scripts/build.sh b/grafana-mixin/scripts/build.sh new file mode 100755 index 0000000..b6d93c2 --- /dev/null +++ b/grafana-mixin/scripts/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -eo pipefail + +cd "$(dirname "$0")"/.. + +mixtool generate all mixin.libsonnet diff --git a/grafana-mixin/scripts/common.sh b/grafana-mixin/scripts/common.sh new file mode 100644 index 0000000..de4e05f --- /dev/null +++ b/grafana-mixin/scripts/common.sh @@ -0,0 +1 @@ +JSONNET_FMT="jsonnetfmt -n 2 --max-blank-lines 2 --string-style s --comment-style s" diff --git a/grafana-mixin/scripts/format.sh b/grafana-mixin/scripts/format.sh new file mode 100755 index 0000000..1d541f1 --- /dev/null +++ b/grafana-mixin/scripts/format.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -eo pipefail + +cd "$(dirname "$0")"/.. + +. scripts/common.sh + +find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + xargs -n 1 -- ${JSONNET_FMT} -i diff --git a/grafana-mixin/scripts/lint.sh b/grafana-mixin/scripts/lint.sh new file mode 100755 index 0000000..f6fbeb1 --- /dev/null +++ b/grafana-mixin/scripts/lint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -eo pipefail + +cd "$(dirname "$0")"/.. + +. scripts/common.sh + +find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + while read f; do \ + ${JSONNET_FMT} "$f" | diff -u "$f" -; \ + done + +mixtool lint mixin.libsonnet diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..a91a888 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,29 @@ +// We set this specifically for 2 reasons. +// 1. It makes sense for both CI tests and local tests to behave the same so issues are found earlier +// 2. Any wrong timezone handling could be hidden if we use UTC/GMT local time (which would happen in CI). +process.env.TZ = 'Pacific/Easter'; + +module.exports = { + verbose: false, + transform: { + '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest', + }, + moduleDirectories: ['node_modules', 'public'], + roots: ['/public/app', '/public/test', '/packages', '/scripts'], + testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + setupFiles: ['jest-canvas-mock', './public/test/jest-shim.ts', './public/test/jest-setup.ts'], + setupFilesAfterEnv: ['./public/test/setupTests.ts'], + snapshotSerializers: ['enzyme-to-json/serializer'], + globals: { + 'ts-jest': { isolatedModules: true }, + __webpack_public_path__: '', // empty string + }, + moduleNameMapper: { + '\\.svg': '/public/test/mocks/svg.ts', + '\\.css': '/public/test/mocks/style.ts', + 'monaco-editor/esm/vs/editor/editor.api': '/public/test/mocks/monaco.ts', + '^react($|/.+)': '/node_modules/react$1', + }, + watchPathIgnorePatterns: ['/node_modules/'], +}; diff --git a/latest.json b/latest.json new file mode 100644 index 0000000..e4f5597 --- /dev/null +++ b/latest.json @@ -0,0 +1,4 @@ +{ + "stable": "7.5.6", + "testing": "7.5.6" +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..ab1b7e5 --- /dev/null +++ b/lerna.json @@ -0,0 +1,8 @@ +{ + "npmClient": "yarn", + "useWorkspaces": true, + "packages": [ + "packages/*" + ], + "version": "8.0.0-beta.1" +} diff --git a/metadata.md b/metadata.md new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..92fb1fa --- /dev/null +++ b/package.json @@ -0,0 +1,340 @@ +{ + "author": "Grafana Labs", + "license": "AGPL-3.0-only", + "private": true, + "name": "grafana", + "version": "8.0.0-beta.1", + "repository": "github:grafana/grafana", + "scripts": { + "api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js", + "build": "node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js", + "dev": "webpack --progress --colors --config scripts/webpack/webpack.dev.js", + "e2e": "./e2e/start-and-run-suite", + "e2e:debug": "./e2e/start-and-run-suite debug", + "e2e:dev": "./e2e/start-and-run-suite dev", + "test": "jest --notify --watch", + "lint": "yarn run lint:ts && yarn run lint:sass", + "lint:ts": "eslint . --ext .js,.tsx,.ts --cache", + "lint:sass": "yarn run sass-lint -c public/sass/.sass-lint.yml 'public/sass/**/*.scss, packages/**/*.scss' -v -i '**/node_modules/**/*.scss'", + "test:ci": "mkdir -p reports/junit && export JEST_JUNIT_OUTPUT_DIR=reports/junit && jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", + "lint:fix": "yarn lint --fix", + "packages:build": "lerna run clean && lerna run build --ignore @grafana-plugins/input-datasource", + "packages:docsExtract": "rm -rf ./reports/docs && lerna run docsExtract", + "packages:docsToMarkdown": "api-documenter markdown --input-folder ./reports/docs/ --output-folder ./docs/sources/packages_api/ --hugo", + "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", + "packages:publish": "lerna publish from-package --contents dist", + "packages:publishCanary": "lerna publish from-package --contents dist --dist-tag canary --yes", + "packages:publishLatest": "lerna publish from-package --contents dist --yes", + "packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes", + "packages:publishDev": "lerna publish from-package --contents dist --dist-tag dev --yes --registry http://grafana-npm.local:4873 --force-publish=*", + "packages:typecheck": "lerna run typecheck", + "packages:clean": "lerna run clean", + "precommit": "yarn run lint-staged", + "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", + "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss,js}\" --write", + "start": "grafana-toolkit core:start --watchTheme", + "start:hot": "grafana-toolkit core:start --hot --watchTheme", + "start:ignoreTheme": "grafana-toolkit core:start --hot", + "start:noTsCheck": "grafana-toolkit core:start --noTsCheck", + "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json", + "storybook": "yarn workspace @grafana/ui storybook --ci", + "storybook:build": "yarn workspace @grafana/ui storybook:build", + "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", + "typecheck": "tsc --noEmit && yarn run packages:typecheck", + "plugins:build-bundled": "grafana-toolkit plugin:bundle-managed", + "watch": "yarn start -d watch,start core:start --watchTheme", + "ci:test-frontend": "yarn run prettier:check && yarn run typecheck && yarn run lint && yarn run test:ci && yarn grafana-toolkit node-version-check && ./scripts/ci-check-strict.sh" + }, + "grafana": { + "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-0/", + "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" + }, + "husky": { + "hooks": { + "pre-commit": "yarn run precommit" + } + }, + "lint-staged": { + "*.{js,ts,tsx}": [ + "eslint --ext .js,.tsx,.ts --cache --fix" + ], + "*.{json,scss}": [ + "prettier --write" + ], + "*pkg/**/*.go": [ + "gofmt -w -s" + ] + }, + "devDependencies": { + "@babel/core": "7.13.14", + "@babel/plugin-proposal-class-properties": "7.13.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "7.13.8", + "@babel/plugin-proposal-object-rest-spread": "7.13.8", + "@babel/plugin-proposal-optional-chaining": "7.13.12", + "@babel/plugin-proposal-private-methods": "7.13.0", + "@babel/plugin-syntax-dynamic-import": "7.8.3", + "@babel/plugin-transform-react-constant-elements": "7.13.13", + "@babel/plugin-transform-runtime": "^7.13.10", + "@babel/preset-env": "7.13.12", + "@babel/preset-react": "7.13.13", + "@babel/preset-typescript": "7.13.0", + "@grafana/api-documenter": "7.11.2", + "@grafana/api-extractor": "7.10.1", + "@grafana/eslint-config": "2.4.0", + "@kusto/monaco-kusto": "3.2.7", + "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", + "@testing-library/jest-dom": "5.11.5", + "@testing-library/react": "11.1.2", + "@testing-library/react-hooks": "^3.2.1", + "@testing-library/user-event": "^12.1.3", + "@types/angular": "1.6.56", + "@types/angular-route": "1.7.0", + "@types/classnames": "2.2.9", + "@types/clipboard": "2.0.1", + "@types/d3": "5.7.2", + "@types/d3-force": "^2.1.0", + "@types/d3-scale-chromatic": "1.3.1", + "@types/debounce-promise": "3.1.3", + "@types/enzyme": "3.10.5", + "@types/enzyme-adapter-react-16": "1.0.6", + "@types/file-saver": "2.0.1", + "@types/history": "^4.7.8", + "@types/is-hotkey": "0.1.1", + "@types/jest": "26.0.15", + "@types/jquery": "3.3.38", + "@types/lodash": "4.14.149", + "@types/logfmt": "^1.2.1", + "@types/lru-cache": "^5.1.0", + "@types/moment-timezone": "0.5.13", + "@types/mousetrap": "1.6.3", + "@types/node": "13.7.0", + "@types/papaparse": "5.2.0", + "@types/prismjs": "1.16.0", + "@types/react": "16.9.9", + "@types/react-beautiful-dnd": "12.1.2", + "@types/react-dom": "16.9.9", + "@types/react-grid-layout": "1.1.1", + "@types/react-highlight-words": "^0.16.2", + "@types/react-redux": "7.1.7", + "@types/react-router-dom": "^5.1.7", + "@types/react-select": "4.0.13", + "@types/react-test-renderer": "16.9.2", + "@types/react-transition-group": "4.4.0", + "@types/react-window": "1.8.1", + "@types/redux-logger": "3.0.7", + "@types/redux-mock-store": "1.0.2", + "@types/reselect": "2.2.0", + "@types/semver": "^6.0.0", + "@types/slate": "0.47.1", + "@types/slate-plain-serializer": "0.6.1", + "@types/slate-react": "0.22.5", + "@types/testing-library__jest-dom": "5.9.5", + "@types/testing-library__react-hooks": "^3.2.0", + "@types/tinycolor2": "1.4.2", + "@typescript-eslint/eslint-plugin": "4.22.0", + "@typescript-eslint/parser": "4.22.0", + "@wojtekmaj/enzyme-adapter-react-17": "0.3.1", + "angular-mocks": "1.6.6", + "autoprefixer": "9.7.4", + "axios": "0.21.1", + "babel-jest": "26.6.3", + "babel-loader": "8.2.2", + "babel-plugin-angularjs-annotate": "0.10.0", + "clean-webpack-plugin": "3.0.0", + "css-loader": "3.4.2", + "enzyme": "3.11.0", + "enzyme-to-json": "3.4.4", + "es-abstract": "1.18.0-next.1", + "es6-promise": "4.2.8", + "es6-shim": "0.35.5", + "eslint": "7.21.0", + "eslint-config-prettier": "7.2.0", + "eslint-plugin-jsdoc": "31.6.1", + "eslint-plugin-lodash": "^7.2.0", + "eslint-plugin-no-only-tests": "2.4.0", + "eslint-plugin-prettier": "3.3.1", + "eslint-plugin-react": "7.22.0", + "eslint-plugin-react-hooks": "4.2.0", + "expect.js": "0.3.1", + "expose-loader": "0.7.5", + "file-loader": "5.0.2", + "fork-ts-checker-webpack-plugin": "6.1.1", + "fs-extra": "9.1.0", + "gaze": "1.1.3", + "glob": "7.1.6", + "html-loader": "0.5.5", + "html-webpack-harddisk-plugin": "1.0.1", + "html-webpack-plugin": "3.2.0", + "husky": "4.2.1", + "iconscout-unicons-tarball": "https://github.com/grafana/icons/tarball/9728be621a4e7d891611149c9cd179e793f790a7", + "jest": "26.6.3", + "jest-canvas-mock": "2.3.0", + "jest-date-mock": "1.0.8", + "jest-matcher-utils": "26.0.0", + "lerna": "^3.22.1", + "lint-staged": "10.0.7", + "mini-css-extract-plugin": "0.9.0", + "mocha": "7.0.1", + "module-alias": "2.2.2", + "mutationobserver-shim": "0.3.3", + "ngtemplate-loader": "2.0.1", + "optimize-css-assets-webpack-plugin": "5.0.4", + "postcss-browser-reporter": "0.6.0", + "postcss-loader": "3.0.0", + "postcss-reporter": "6.0.1", + "prettier": "2.2.1", + "raw-loader": "4.0.2", + "react-hot-loader": "4.8.0", + "react-select-event": "^5.1.0", + "react-test-renderer": "16.12.0", + "redux-mock-store": "1.5.4", + "regexp-replace-loader": "1.0.1", + "rimraf": "3.0.1", + "rxjs-spy": "^7.5.1", + "sass": "1.27.0", + "sass-lint": "1.12.1", + "sass-loader": "8.0.2", + "sinon": "8.1.1", + "style-loader": "1.1.3", + "terser-webpack-plugin": "2.3.7", + "testing-library-selector": "^0.1.3", + "ts-jest": "26.4.4", + "ts-node": "9.0.0", + "tslib": "2.1.0", + "typescript": "4.2.4", + "webpack": "4.41.5", + "webpack-bundle-analyzer": "3.6.0", + "webpack-cleanup-plugin": "0.5.1", + "webpack-cli": "3.3.10", + "webpack-dev-server": "3.11.1", + "webpack-merge": "4.2.2", + "worker-loader": "^3.0.8", + "zone.js": "0.7.8" + }, + "dependencies": { + "@emotion/css": "11.1.3", + "@emotion/eslint-plugin": "11.2.0", + "@emotion/react": "11.1.5", + "@grafana/aws-sdk": "0.0.3", + "@grafana/slate-react": "0.22.10-grafana", + "@popperjs/core": "2.5.4", + "@reduxjs/toolkit": "1.5.0", + "@sentry/browser": "5.25.0", + "@sentry/types": "5.24.2", + "@sentry/utils": "5.24.2", + "@types/braintree__sanitize-url": "4.0.0", + "@types/common-tags": "^1.8.0", + "@types/hoist-non-react-statics": "3.3.1", + "@types/jsurl": "^1.2.28", + "@types/md5": "^2.1.33", + "@types/pluralize": "^0.0.29", + "@types/react-loadable": "5.5.2", + "@types/react-virtualized-auto-sizer": "1.0.0", + "@types/uuid": "8.3.0", + "@welldone-software/why-did-you-render": "4.0.6", + "abortcontroller-polyfill": "1.4.0", + "angular": "1.8.2", + "angular-bindonce": "0.3.1", + "angular-route": "1.8.2", + "angular-sanitize": "1.8.2", + "baron": "3.0.3", + "brace": "0.11.1", + "calculate-size": "1.1.1", + "centrifuge": "2.7.5", + "classnames": "2.2.6", + "clipboard": "2.0.4", + "common-tags": "^1.8.0", + "copy-webpack-plugin": "6.4.1", + "core-js": "3.10.0", + "d3": "5.15.0", + "d3-force": "^2.1.1", + "d3-scale-chromatic": "1.5.0", + "dangerously-set-html-content": "1.0.6", + "debounce-promise": "3.1.2", + "eventemitter3": "4.0.0", + "fast-json-patch": "2.2.1", + "fast-text-encoding": "^1.0.0", + "file-saver": "2.0.2", + "history": "4.10.1", + "hoist-non-react-statics": "3.3.2", + "immutable": "3.8.2", + "is-hotkey": "0.1.6", + "jquery": "3.5.1", + "json-source-map": "0.6.1", + "jsurl": "^0.1.5", + "lodash": "4.17.21", + "logfmt": "^1.3.2", + "lru-cache": "^5.1.1", + "md5": "^2.2.1", + "memoize-one": "5.1.1", + "moment": "2.24.0", + "moment-timezone": "0.5.28", + "mousetrap": "1.6.5", + "mousetrap-global-bind": "1.1.0", + "nodemon": "2.0.2", + "papaparse": "5.3.0", + "pluralize": "^8.0.0", + "prismjs": "1.23.0", + "prop-types": "15.7.2", + "rc-cascader": "1.0.1", + "re-resizable": "^6.2.0", + "react": "17.0.1", + "react-beautiful-dnd": "13.0.0", + "react-diff-viewer": "^3.1.1", + "react-dom": "17.0.1", + "react-grid-layout": "1.2.0", + "react-highlight-words": "0.17.0", + "react-inlinesvg": "2.3.0", + "react-loadable": "5.5.0", + "react-popper": "2.2.4", + "react-redux": "7.2.0", + "react-reverse-portal": "^2.0.1", + "react-router-dom": "^5.2.0", + "react-select": "4.3.0", + "react-sizeme": "2.6.12", + "react-split-pane": "0.1.89", + "react-transition-group": "4.4.1", + "react-use": "13.27.0", + "react-virtualized-auto-sizer": "1.0.2", + "react-window": "1.8.5", + "redux": "4.0.5", + "redux-logger": "3.0.6", + "redux-thunk": "2.3.0", + "regenerator-runtime": "0.13.3", + "reselect": "4.0.0", + "rst2html": "github:thoward/rst2html#990cb89", + "rxjs": "6.6.3", + "search-query-parser": "1.5.4", + "semver": "^7.1.3", + "slate": "0.47.8", + "slate-plain-serializer": "0.7.10", + "tether": "1.4.7", + "tether-drop": "https://github.com/torkelo/drop", + "tinycolor2": "1.4.1", + "uuid": "8.3.0", + "visjs-network": "4.25.0", + "whatwg-fetch": "3.1.0" + }, + "resolutions": { + "caniuse-db": "1.0.30000772" + }, + "workspaces": { + "packages": [ + "packages/*", + "plugins-bundled/internal/*" + ], + "nohoist": [ + "**/@types/*", + "**/@types/*/**" + ] + }, + "_moduleAliases": { + "puppeteer": "node_modules/puppeteer-core" + }, + "engines": { + "node": ">= 14" + }, + "volta": { + "node": "14.15.1" + } +} diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..1e0ee2c --- /dev/null +++ b/packages/README.md @@ -0,0 +1,115 @@ +# Grafana frontend packages + +This document contains information about Grafana frontend package versioning and releases. + +## Versioning +We use [Lerna](https://github.com/lerna/lerna) for packages versioning and releases. + +All packages are versioned according to the current Grafana version: +- Grafana v6.3.0-alpha1 -> @grafana/* packages @ 6.3.0-alpha.1 +- Grafana v6.2.5 -> @grafana/* packages @ 6.2.5 +- Grafana - main branch version (based on package.json, i.e. 6.4.0-pre) -> @grafana/* packages @ 6.4.0-pre- (see details below about packages publishing channels) + +> Please note that @grafana/toolkit, @grafana/ui, @grafana/data, and @grafana/runtime packages are considered ALPHA even though they are not released as alpha versions. + +### Stable releases +> **Even though packages are released under a stable version, they are considered ALPHA until further notice!** + +Stable releases are published under the `latest` tag on npm. If there was alpha/beta version released previously, the `next` tag is updated to stable version. + +### Alpha and beta releases +Alpha and beta releases are published under the `next` tag on npm. + +### Automatic prereleases +Every commit to main that has changes within the `packages` directory is a subject of npm packages release. *ALL* packages must be released under version from lerna.json file with commit SHA added to it: + +``` +- +``` + +Automatic prereleases are published under the `canary` dist tag to the [github package registry](https://docs.github.com/en/free-pro-team@latest/packages/publishing-and-managing-packages/about-github-packages). + +#### Consuming prereleases + +As mentioned above the `canary` releases are published to the Github package registry rather than the NPM registry. If you wish to make use of these prereleases please follow these steps: + +1. You must use a personal access token to install packages from Github. To create an access token [click here](https://github.com/settings/tokens) and create a token with the `read:packages` scope. Make a copy of the token. +2. Create / modify your `~/.npmrc` file with the following: + +``` +@grafana:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken={INSERT_GH_TOKEN_HERE} +``` + +3. Update the package.json of your project to use either the `canary` channel or a version of the `canary` channel + +```json +// plugin's package.json +{ + ... + "@grafana/data": "canary" +} +``` + +### Manual release + +> All of the steps below must be performed on a release branch, according to Grafana Release Guide. + +> Make sure you are logged in to npm in your terminal and that you are a part of Grafana org on npm. + +1. Run `yarn packages:prepare` script from the root directory. This performs tests on the packages and prompts for the version of the packages. The version should be the same as the one being released. + - Make sure you use semver convention. So, *place a dot between prerelease id and prerelease number*, i.e. 6.3.0-alpha.1 + - Make sure you confirm the version bump when prompted! +2. Commit changes (lerna.json and package.json files) - *"Packages version update: \"* +3. Run `yarn packages:build` script that prepares distribution packages in `packages/grafana-*/dist`. These directories are going to be published to npm. +4. Depending whether or not it's a prerelease: + - When releasing a prerelease run `packages:publishNext` to publish new versions. + - When releasing a stable version run `packages:publishLatest` to publish new versions. + +5. Push version commit to the release branch. + +### Building individual packages +To build individual packages, run: + +``` +grafana-toolkit package:build --scope= +``` + +### Setting up @grafana/* packages for local development + +A known issue with @grafana/* packages is that a lot of times we discover problems on canary channel(see [versioning overview](#Versioning)) when the version was already pushed to npm. + +We can easily avoid that by setting up a local packages registry and test the packages before actually publishing to npm. + +In this guide you will set up [Verdaccio](https://verdaccio.org/) registry locally to fake npm registry. This will enable testing @grafana/* packages without the need for pushing to main. + +#### Setting up local npm registry + +From your terminal: +1. Modify `/etc/hosts` file and add the following entry: ```127.0.0.1 grafana-npm.local``` +2. Navigate to `devenv/local-npm` directory. +3. Run `docker-compose up`. This will start your local npm registry, available at http://grafana-npm.local:4873/ +4. Run `npm login --registry=http://grafana-npm.local:4873 --scope=@grafana` . This will allow you to publish any @grafana/* package into the local registry. +5. Run `npm config set @grafana:registry http://grafana-npm.local:4873`. This will config your npm to install @grafana scoped packages from your local registry. + +#### Publishing packages to local npm registry + +You need to follow [manual packages release procedure](#manual-release). The only difference is you need to run `yarn packages:publishDev` task in order to publish to you local registry. + +From your terminal: +1. Run `yarn packages:prepare`. +2. Commit changes in package.json and lerna.json files +3. Build packages: `yarn packages:build` +4. Run `yarn packages:publishDev`. +5. Navigate to http://grafana-npm.local:4873 and verify that version was published + +Locally published packages will be published under `dev` channel, so in your plugin package.json file you can use that channel. For example: + +``` +// plugin's package.json + +{ + ... + "@grafana/data": "dev" +} +``` diff --git a/packages/grafana-data/.eslintrc b/packages/grafana-data/.eslintrc new file mode 100644 index 0000000..41e607e --- /dev/null +++ b/packages/grafana-data/.eslintrc @@ -0,0 +1,13 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@grafana/runtime", "@grafana/ui", "@grafana/data", "@grafana/e2e/*"] }] + }, + "overrides": [ + { + "files": ["**/*.test.{ts,tsx}"], + "rules": { + "no-restricted-imports": "off" + } + } + ] +} diff --git a/packages/grafana-data/CHANGELOG.md b/packages/grafana-data/CHANGELOG.md new file mode 100644 index 0000000..556d424 --- /dev/null +++ b/packages/grafana-data/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-data/LICENSE_APACHE2 b/packages/grafana-data/LICENSE_APACHE2 new file mode 100644 index 0000000..373dde5 --- /dev/null +++ b/packages/grafana-data/LICENSE_APACHE2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/grafana-data/README.md b/packages/grafana-data/README.md new file mode 100644 index 0000000..a1823ca --- /dev/null +++ b/packages/grafana-data/README.md @@ -0,0 +1,5 @@ +# Grafana Data Library + +> **@grafana/data is currently in BETA**. + +This package holds the root data types and functions used within Grafana. diff --git a/packages/grafana-data/api-extractor.json b/packages/grafana-data/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-data/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-data/index.js b/packages/grafana-data/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-data/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json new file mode 100644 index 0000000..6c88402 --- /dev/null +++ b/packages/grafana-data/package.json @@ -0,0 +1,58 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/data", + "version": "8.0.0-beta.1", + "description": "Grafana Data Library", + "keywords": [ + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-data" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=data", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@braintree/sanitize-url": "5.0.1", + "@types/d3-interpolate": "^1.3.1", + "eventemitter3": "4.0.7", + "lodash": "4.17.21", + "marked": "2.0.1", + "rxjs": "6.6.3", + "xss": "1.0.6" + }, + "devDependencies": { + "@grafana/tsconfig": "^1.0.0-rc1", + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-json": "4.1.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/braintree__sanitize-url": "4.0.0", + "@types/jest": "26.0.15", + "@types/jquery": "3.3.38", + "@types/lodash": "4.14.123", + "@types/marked": "1.1.0", + "@types/node": "10.14.1", + "@types/papaparse": "5.2.0", + "@types/react": "16.9.9", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/sinon": "^7.5.2", + "pretty-format": "25.1.0", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "sinon": "8.1.1", + "tinycolor2": "1.4.1", + "typescript": "4.2.4" + } +} diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts new file mode 100644 index 0000000..830f86c --- /dev/null +++ b/packages/grafana-data/rollup.config.ts @@ -0,0 +1,38 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import json from '@rollup/plugin-json'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + file: `dist/index.${env}.js`, + name: libraryName, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + ], + external: ['lodash', 'rxjs'], // Use Lodash, rxjs from grafana + plugins: [ + json({ + include: ['../../node_modules/moment-timezone/data/packed/latest.json'], + }), + commonjs({ + include: /node_modules/, + }), + resolve(), + sourceMaps(), + env === 'production' && terser(), + ], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts new file mode 100644 index 0000000..bdc0679 --- /dev/null +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts @@ -0,0 +1,115 @@ +import { ArrayDataFrame } from './ArrayDataFrame'; +import { toDataFrameDTO } from './processDataFrame'; +import { FieldType, DataFrame } from '../types'; + +describe('Array DataFrame', () => { + const input = [ + { name: 'first', value: 1, time: 123 }, + { name: 'second', value: 2, time: 456, extra: 'here' }, + { name: 'third', value: 3, time: 789 }, + { name: '4th (NaN)', value: NaN, time: 1000 }, + { name: '5th (Null)', value: null, time: 1100 }, + ]; + + const frame = new ArrayDataFrame(input); + frame.name = 'Hello'; + frame.refId = 'Z'; + frame.setFieldType('phantom', FieldType.string, (v) => '🦥'); + const field = frame.fields.find((f) => f.name === 'value'); + field!.config.unit = 'kwh'; + + test('Should support functional methods', () => { + const expectedNames = input.map((row) => row.name); + + // Check map + expect(frame.map((row) => row.name)).toEqual(expectedNames); + + let names: string[] = []; + for (const row of frame) { + names.push(row.name); + } + expect(names).toEqual(expectedNames); + + names = []; + frame.forEach((row) => { + names.push(row.name); + }); + expect(names).toEqual(expectedNames); + }); + + test('Should convert an array of objects to a dataframe', () => { + expect(toDataFrameDTO(frame)).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "name", + "type": "string", + "values": Array [ + "first", + "second", + "third", + "4th (NaN)", + "5th (Null)", + ], + }, + Object { + "config": Object { + "unit": "kwh", + }, + "labels": undefined, + "name": "value", + "type": "number", + "values": Array [ + 1, + 2, + 3, + NaN, + null, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 123, + 456, + 789, + 1000, + 1100, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "phantom", + "type": "string", + "values": Array [ + "🦥", + "🦥", + "🦥", + "🦥", + "🦥", + ], + }, + ], + "meta": undefined, + "name": "Hello", + "refId": "Z", + } + `); + }); + + test('Survives ES6 operations', () => { + const copy: DataFrame = { + ...frame, + name: 'hello', + }; + expect(copy.fields).toEqual(frame.fields); + expect(copy.length).toEqual(frame.length); + expect(copy.length).toEqual(input.length); + }); +}); diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts new file mode 100644 index 0000000..ed05596 --- /dev/null +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts @@ -0,0 +1,112 @@ +import { Field, FieldType, DataFrame } from '../types/dataFrame'; +import { vectorToArray } from '../vector/vectorToArray'; +import { Vector, QueryResultMeta } from '../types'; +import { guessFieldTypeFromNameAndValue, toDataFrameDTO } from './processDataFrame'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +export type ValueConverter = (val: any) => T; + +const NOOP: ValueConverter = (v) => v; + +class ArrayPropertyVector implements Vector { + converter = NOOP; + + constructor(private source: any[], private prop: string) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.converter(this.source[index][this.prop]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} + +/** + * The ArrayDataFrame takes an array of objects and presents it as a DataFrame + * + * @alpha + */ +export class ArrayDataFrame extends FunctionalVector implements DataFrame { + name?: string; + refId?: string; + meta?: QueryResultMeta; + + fields: Field[] = []; + length = 0; + + constructor(private source: T[], names?: string[]) { + super(); + + this.length = source.length; + const first: any = source.length ? source[0] : {}; + if (names) { + this.fields = names.map((name) => { + return { + name, + type: guessFieldTypeFromNameAndValue(name, first[name]), + config: {}, + values: new ArrayPropertyVector(source, name), + }; + }); + } else { + this.setFieldsFromObject(first); + } + } + + /** + * Add a field for each property in the object. This will guess the type + */ + setFieldsFromObject(obj: any) { + this.fields = Object.keys(obj).map((name) => { + return { + name, + type: guessFieldTypeFromNameAndValue(name, obj[name]), + config: {}, + values: new ArrayPropertyVector(this.source, name), + }; + }); + } + + /** + * Configure how the object property is passed to the data frame + */ + setFieldType(name: string, type: FieldType, converter?: ValueConverter): Field { + let field = this.fields.find((f) => f.name === name); + if (field) { + field.type = type; + } else { + field = { + name, + type, + config: {}, + values: new ArrayPropertyVector(this.source, name), + }; + this.fields.push(field); + } + (field.values as any).converter = converter ?? NOOP; + return field; + } + + /** + * Get an object with a property for each field in the DataFrame + */ + get(idx: number): T { + return this.source[idx]; + } + + /** + * The simplified JSON values used in JSON.stringify() + */ + toJSON() { + return toDataFrameDTO(this); + } +} diff --git a/packages/grafana-data/src/dataframe/CircularDataFrame.ts b/packages/grafana-data/src/dataframe/CircularDataFrame.ts new file mode 100644 index 0000000..890be6c --- /dev/null +++ b/packages/grafana-data/src/dataframe/CircularDataFrame.ts @@ -0,0 +1,22 @@ +import { MutableDataFrame } from './MutableDataFrame'; +import { CircularVector } from '../vector/CircularVector'; + +interface CircularOptions { + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * This dataframe can have values constantly added, and will never + * exceed the given capacity + */ +export class CircularDataFrame extends MutableDataFrame { + constructor(options: CircularOptions) { + super(undefined, (buffer?: any[]) => { + return new CircularVector({ + ...options, + buffer, + }); + }); + } +} diff --git a/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts b/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts new file mode 100644 index 0000000..b9c73ae --- /dev/null +++ b/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts @@ -0,0 +1,86 @@ +import { FieldType } from '../types/dataFrame'; +import { DataFrameJSON, dataFrameFromJSON } from './DataFrameJSON'; + +describe('DataFrame JSON', () => { + describe('when called with a DataFrame', () => { + it('should decode values not supported natively in JSON (e.g. NaN, Infinity)', () => { + const json: DataFrameJSON = { + schema: { + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'name', type: FieldType.string }, + { name: 'value', type: FieldType.number }, + ], + }, + data: { + values: [ + [100, 200, 300], + ['a', 'b', 'c'], + [1, 2, 3], + ], + entities: [ + null, // nothing to replace, but keeps the index + { NaN: [0], Inf: [1], Undef: [2] }, + { NegInf: [2] }, + ], + }, + }; + + const frame = dataFrameFromJSON(json); + expect(frame).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "entities": Object {}, + "name": "time", + "type": "time", + "values": Array [ + 100, + 200, + 300, + ], + }, + Object { + "config": Object {}, + "entities": Object { + "Inf": Array [ + 1, + ], + "NaN": Array [ + 0, + ], + "Undef": Array [ + 2, + ], + }, + "name": "name", + "type": "string", + "values": Array [ + NaN, + Infinity, + undefined, + ], + }, + Object { + "config": Object {}, + "entities": Object { + "NegInf": Array [ + 2, + ], + }, + "name": "value", + "type": "number", + "values": Array [ + 1, + 2, + -Infinity, + ], + }, + ], + "length": 3, + } + `); + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/DataFrameJSON.ts b/packages/grafana-data/src/dataframe/DataFrameJSON.ts new file mode 100644 index 0000000..ae80255 --- /dev/null +++ b/packages/grafana-data/src/dataframe/DataFrameJSON.ts @@ -0,0 +1,216 @@ +import { DataFrame, FieldType, FieldConfig, Labels, QueryResultMeta } from '../types'; +import { ArrayVector } from '../vector'; +import { guessFieldTypeFromNameAndValue } from './processDataFrame'; + +/** + * The JSON transfer object for DataFrames. Values are stored in simple JSON + * + * @alpha + */ +export interface DataFrameJSON { + /** + * The schema defines the field type and configuration. + */ + schema?: DataFrameSchema; + + /** + * The field data + */ + data?: DataFrameData; +} + +/** + * @alpha + */ +export interface DataFrameData { + /** + * A columnar store that matches fields defined by schema. + */ + values: any[][]; + + /** + * Since JSON cannot encode NaN, Inf, -Inf, and undefined, these entities + * are decoded after JSON.parse() using this struct + */ + entities?: Array; + + /** + * Holds value bases per field so we can encode numbers from fixed points + * e.g. [1612900958, 1612900959, 1612900960] -> 1612900958 + [0, 1, 2] + */ + bases?: number[]; + + /** + * Holds value multipliers per field so we can encode large numbers concisely + * e.g. [4900000000, 35000000000] -> 1e9 + [4.9, 35] + */ + factors?: number[]; + + /** + * Holds enums per field so we can encode recurring values as ints + * e.g. ["foo", "foo", "baz", "foo"] -> ["foo", "baz"] + [0,0,1,0] + */ + enums?: any[][]; +} + +/** + * The JSON transfer object for DataFrames. Values are stored in simple JSON + * + * @alpha + */ +export interface DataFrameSchema { + /** + * Matches the query target refId + */ + refId?: string; + + /** + * Initial response global metadata + */ + meta?: QueryResultMeta; + + /** + * Frame name + */ + name?: string; + + /** + * Field definition without any metadata + */ + fields: FieldSchema[]; +} + +/** + * Field object passed over JSON + * + * @alpha + */ +export interface FieldSchema { + name: string; // The column name + type?: FieldType; + config?: FieldConfig; + labels?: Labels; +} + +/** + * Since JSON cannot encode NaN, Inf, -Inf, and undefined, the locations + * of these entities in field value arrays are stored here for restoration + * after JSON.parse() + * + * @alpha + */ +export interface FieldValueEntityLookup { + NaN?: number[]; + Undef?: number[]; // Missing because of absence or join + Inf?: number[]; + NegInf?: number[]; +} + +const ENTITY_MAP: Record = { + Inf: Infinity, + NegInf: -Infinity, + Undef: undefined, + NaN: NaN, +}; + +/** + * @internal use locally + */ +export function decodeFieldValueEntities(lookup: FieldValueEntityLookup, values: any[]) { + if (!lookup || !values) { + return; + } + for (const key in lookup) { + const repl = ENTITY_MAP[key as keyof FieldValueEntityLookup]; + for (const idx of lookup[key as keyof FieldValueEntityLookup]!) { + if (idx < values.length) { + values[idx] = repl; + } + } + } +} + +function guessFieldType(name: string, values: any[]): FieldType { + for (const v of values) { + if (v != null) { + return guessFieldTypeFromNameAndValue(name, v); + } + } + return FieldType.other; +} + +/** + * NOTE: dto.data.values will be mutated and decoded/inflated using entities,bases,factors,enums + * + * @alpha + */ +export function dataFrameFromJSON(dto: DataFrameJSON): DataFrame { + const { schema, data } = dto; + + if (!schema || !schema.fields) { + throw new Error('JSON needs a fields definition'); + } + + // Find the longest field length + const length = data ? data.values.reduce((max, vals) => Math.max(max, vals.length), 0) : 0; + + const fields = schema.fields.map((f, index) => { + let buffer = data ? data.values[index] : []; + let origLen = buffer.length; + + if (origLen !== length) { + buffer.length = length; + // avoid sparse arrays + buffer.fill(undefined, origLen); + } + + let entities: FieldValueEntityLookup | undefined | null; + + if ((entities = data && data.entities && data.entities[index])) { + decodeFieldValueEntities(entities, buffer); + } + + // TODO: expand arrays further using bases,factors,enums + + return { + ...f, + type: f.type ?? guessFieldType(f.name, buffer), + config: f.config ?? {}, + values: new ArrayVector(buffer), + // the presence of this prop is an optimization signal & lookup for consumers + entities: entities ?? {}, + }; + }); + + return { + ...schema, + fields, + length, + }; +} + +/** + * This converts DataFrame to a json representation with distinct schema+data + * + * @alpha + */ +export function dataFrameToJSON(frame: DataFrame): DataFrameJSON { + const data: DataFrameData = { + values: [], + }; + const schema: DataFrameSchema = { + refId: frame.refId, + meta: frame.meta, + name: frame.name, + fields: frame.fields.map((f) => { + const { values, ...sfield } = f; + data.values.push(values.toArray()); + return sfield; + }), + }; + + return { + schema, + data, + }; +} diff --git a/packages/grafana-data/src/dataframe/DataFrameView.test.ts b/packages/grafana-data/src/dataframe/DataFrameView.test.ts new file mode 100644 index 0000000..a13471c --- /dev/null +++ b/packages/grafana-data/src/dataframe/DataFrameView.test.ts @@ -0,0 +1,84 @@ +import { FieldType, DataFrameDTO } from '../types/dataFrame'; +import { DateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; +import { DataFrameView } from './DataFrameView'; + +interface MySpecialObject { + time: DateTime; + name: string; + value: number; + more: string; // MISSING +} + +describe('dataFrameView', () => { + const frame: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + const ext = new MutableDataFrame(frame); + const vector = new DataFrameView(ext); + + it('Should get a typed vector', () => { + expect(vector.length).toEqual(3); + + const first = vector.get(0); + expect(first.time).toEqual(100); + expect(first.name).toEqual('a'); + expect(first.value).toEqual(1); + expect(first.more).toBeUndefined(); + }); + + it('Should support the spread operator', () => { + expect(vector.length).toEqual(3); + + const first = vector.get(0); + const copy = { ...first }; + expect(copy.time).toEqual(100); + expect(copy.name).toEqual('a'); + expect(copy.value).toEqual(1); + expect(copy.more).toBeUndefined(); + }); + + it('Should support array indexes', () => { + expect(vector.length).toEqual(3); + + const first = vector.get(0) as any; + expect(first[0]).toEqual(100); + expect(first[1]).toEqual('a'); + expect(first[2]).toEqual(1); + expect(first[3]).toBeUndefined(); + }); + + it('Should advertise the property names for each field', () => { + expect(vector.length).toEqual(3); + const first = vector.get(0); + const keys = Object.keys(first); + expect(keys).toEqual(['time', 'name', 'value']); + }); + + it('has a weird side effect that the object values change after interaction', () => { + expect(vector.length).toEqual(3); + + // Get the first value + const first = vector.get(0); + expect(first.name).toEqual('a'); + + // Then get the second one + const second = vector.get(1); + + // the values for 'first' have changed + expect(first.name).toEqual('b'); + expect(first.name).toEqual(second.name); + }); + + it('toJSON returns plain object', () => { + expect(vector.toJSON()[0]).toEqual({ + time: 100, + name: 'a', + value: 1, + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/DataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts new file mode 100644 index 0000000..b960558 --- /dev/null +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -0,0 +1,99 @@ +import { DataFrame } from '../types/dataFrame'; +import { DisplayProcessor } from '../types'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +/** + * This abstraction will present the contents of a DataFrame as if + * it were a well typed javascript object Vector. + * + * @remarks + * The {@link DataFrameView.get} is optimized for use in a loop and will return same object. + * See function for more details. + * + * @typeParam T - Type of object stored in the DataFrame. + * @beta + */ +export class DataFrameView extends FunctionalVector { + private index = 0; + private obj: T; + + constructor(private data: DataFrame) { + super(); + const obj = ({} as unknown) as T; + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + const getter = () => field.values.get(this.index); + + if (!(obj as any).hasOwnProperty(field.name)) { + Object.defineProperty(obj, field.name, { + enumerable: true, // Shows up as enumerable property + get: getter, + }); + } + + Object.defineProperty(obj, i, { + enumerable: false, // Don't enumerate array index + get: getter, + }); + } + + this.obj = obj; + } + + get dataFrame() { + return this.data; + } + + get length() { + return this.data.length; + } + + /** + * Helper function to return the {@link DisplayProcessor} for a given field column. + * @param colIndex - the field column index for the data frame. + */ + getFieldDisplayProcessor(colIndex: number): DisplayProcessor | undefined { + if (!this.dataFrame || !this.dataFrame.fields) { + return undefined; + } + + const field = this.dataFrame.fields[colIndex]; + + if (!field || !field.display) { + return undefined; + } + + return field.display; + } + + /** + * The contents of the object returned from this function + * are optimized for use in a loop. All calls return the same object + * but the index has changed. + * + * @example + * ```typescript + * // `first`, `second` and `third` will all point to the same contents at index 2: + * const first = view.get(0); + * const second = view.get(1); + * const third = view.get(2); + * + * // If you need three different objects, consider something like: + * const first = { ...view.get(0) }; + * const second = { ...view.get(1) }; + * const third = { ...view.get(2) }; + * ``` + * @param idx - The index of the object you currently are inspecting + */ + get(idx: number) { + this.index = idx; + return this.obj; + } + + toArray(): T[] { + return new Array(this.data.length) + .fill(0) // Needs to make a full copy + .map((_, i) => ({ ...this.get(i) })); + } +} diff --git a/packages/grafana-data/src/dataframe/FieldCache.test.ts b/packages/grafana-data/src/dataframe/FieldCache.test.ts new file mode 100644 index 0000000..c35d899 --- /dev/null +++ b/packages/grafana-data/src/dataframe/FieldCache.test.ts @@ -0,0 +1,93 @@ +import { FieldCache } from './FieldCache'; +import { FieldType } from '../types/dataFrame'; +import { toDataFrame } from './processDataFrame'; + +describe('FieldCache', () => { + it('when creating a new FieldCache from fields should be able to query cache', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'string', type: FieldType.string }, + { name: 'number', type: FieldType.number }, + { name: 'boolean', type: FieldType.boolean }, + { name: 'other', type: FieldType.other }, + { name: 'undefined' }, + ], + }); + const fieldCache = new FieldCache(frame); + const allFields = fieldCache.getFields(); + expect(allFields).toHaveLength(6); + + const expectedFieldNames = ['time', 'string', 'number', 'boolean', 'other', 'undefined']; + + expect(allFields.map((f) => f.name)).toEqual(expectedFieldNames); + + expect(fieldCache.hasFieldOfType(FieldType.time)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.string)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.number)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.boolean)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.other)).toBeTruthy(); + + expect(fieldCache.getFields(FieldType.time).map((f) => f.name)).toEqual([expectedFieldNames[0]]); + expect(fieldCache.getFields(FieldType.string).map((f) => f.name)).toEqual([expectedFieldNames[1]]); + expect(fieldCache.getFields(FieldType.number).map((f) => f.name)).toEqual([expectedFieldNames[2]]); + expect(fieldCache.getFields(FieldType.boolean).map((f) => f.name)).toEqual([expectedFieldNames[3]]); + expect(fieldCache.getFields(FieldType.other).map((f) => f.name)).toEqual([ + expectedFieldNames[4], + expectedFieldNames[5], + ]); + + expect(fieldCache.fields[0].name).toEqual(expectedFieldNames[0]); + expect(fieldCache.fields[1].name).toEqual(expectedFieldNames[1]); + expect(fieldCache.fields[2].name).toEqual(expectedFieldNames[2]); + expect(fieldCache.fields[3].name).toEqual(expectedFieldNames[3]); + expect(fieldCache.fields[4].name).toEqual(expectedFieldNames[4]); + expect(fieldCache.fields[5].name).toEqual(expectedFieldNames[5]); + expect(fieldCache.fields[6]).toBeUndefined(); + + expect(fieldCache.getFirstFieldOfType(FieldType.time)!.name).toEqual(expectedFieldNames[0]); + expect(fieldCache.getFirstFieldOfType(FieldType.string)!.name).toEqual(expectedFieldNames[1]); + expect(fieldCache.getFirstFieldOfType(FieldType.number)!.name).toEqual(expectedFieldNames[2]); + expect(fieldCache.getFirstFieldOfType(FieldType.boolean)!.name).toEqual(expectedFieldNames[3]); + expect(fieldCache.getFirstFieldOfType(FieldType.other)!.name).toEqual(expectedFieldNames[4]); + + expect(fieldCache.hasFieldNamed('tim')).toBeFalsy(); + expect(fieldCache.hasFieldNamed('time')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('string')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('number')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('boolean')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('other')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('undefined')).toBeTruthy(); + + expect(fieldCache.getFieldByName('time')!.name).toEqual(expectedFieldNames[0]); + expect(fieldCache.getFieldByName('string')!.name).toEqual(expectedFieldNames[1]); + expect(fieldCache.getFieldByName('number')!.name).toEqual(expectedFieldNames[2]); + expect(fieldCache.getFieldByName('boolean')!.name).toEqual(expectedFieldNames[3]); + expect(fieldCache.getFieldByName('other')!.name).toEqual(expectedFieldNames[4]); + expect(fieldCache.getFieldByName('undefined')!.name).toEqual(expectedFieldNames[5]); + expect(fieldCache.getFieldByName('null')).toBeUndefined(); + }); + + describe('field retrieval', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [4, 5, 6] }, + ], + }); + const ext = new FieldCache(frame); + + it('should get the first field with a duplicate name', () => { + const field = ext.getFieldByName('value'); + expect(field!.name).toEqual('value'); + expect(field!.values.toArray()).toEqual([1, 2, 3]); + }); + + it('should return index of the field', () => { + const field = ext.getFirstFieldOfType(FieldType.number); + expect(field!.index).toEqual(2); + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/FieldCache.ts b/packages/grafana-data/src/dataframe/FieldCache.ts new file mode 100644 index 0000000..2834d47 --- /dev/null +++ b/packages/grafana-data/src/dataframe/FieldCache.ts @@ -0,0 +1,89 @@ +import { Field, DataFrame, FieldType, guessFieldTypeForField } from '../index'; + +export interface FieldWithIndex extends Field { + index: number; +} + +export class FieldCache { + fields: FieldWithIndex[] = []; + + private fieldByName: { [key: string]: FieldWithIndex } = {}; + private fieldByType: { [key: string]: FieldWithIndex[] } = {}; + + constructor(data: DataFrame) { + this.fields = data.fields.map((field, idx) => ({ + ...field, + index: idx, + })); + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + // Make sure it has a type + if (field.type === FieldType.other) { + const t = guessFieldTypeForField(field); + if (t) { + field.type = t; + } + } + if (!this.fieldByType[field.type]) { + this.fieldByType[field.type] = []; + } + this.fieldByType[field.type].push({ + ...field, + index: i, + }); + + if (this.fieldByName[field.name]) { + console.warn('Duplicate field names in DataFrame: ', field.name); + } else { + this.fieldByName[field.name] = { ...field, index: i }; + } + } + } + + getFields(type?: FieldType): FieldWithIndex[] { + if (!type) { + return [...this.fields]; // All fields + } + const fields = this.fieldByType[type]; + if (fields) { + return [...fields]; + } + return []; + } + + hasFieldOfType(type: FieldType): boolean { + const types = this.fieldByType[type]; + return types && types.length > 0; + } + + getFirstFieldOfType(type: FieldType, includeHidden = false): FieldWithIndex | undefined { + const fields = this.fieldByType[type]; + const firstField = fields.find((field) => includeHidden || !field.config.custom?.hidden); + return firstField; + } + + hasFieldNamed(name: string): boolean { + return !!this.fieldByName[name]; + } + + hasFieldWithNameAndType(name: string, type: FieldType): boolean { + return !!this.fieldByName[name] && this.fieldByType[type].filter((field) => field.name === name).length > 0; + } + + /** + * Returns the first field with the given name. + */ + getFieldByName(name: string): FieldWithIndex | undefined { + return this.fieldByName[name]; + } + + /** + * Returns the fields with the given label. + */ + getFieldsByLabel(label: string, value: string): FieldWithIndex[] { + return Object.values(this.fieldByName).filter((f) => { + return f.labels && f.labels[label] === value; + }); + } +} diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts new file mode 100644 index 0000000..3688161 --- /dev/null +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts @@ -0,0 +1,66 @@ +import { DataFrameDTO, FieldType } from '../types/dataFrame'; +import { MutableDataFrame } from './MutableDataFrame'; + +describe('Reversing DataFrame', () => { + describe('when called with a DataFrame', () => { + it('then it should reverse the order of values in all fields', () => { + const frame: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const helper = new MutableDataFrame(frame); + + expect(helper.fields[0].values.toArray()).toEqual([100, 200, 300]); + expect(helper.fields[1].values.toArray()).toEqual(['a', 'b', 'c']); + expect(helper.fields[2].values.toArray()).toEqual([1, 2, 3]); + + helper.reverse(); + + expect(helper.fields[0].values.toArray()).toEqual([300, 200, 100]); + expect(helper.fields[1].values.toArray()).toEqual(['c', 'b', 'a']); + expect(helper.fields[2].values.toArray()).toEqual([3, 2, 1]); + }); + }); +}); + +describe('Apending DataFrame', () => { + it('Should append values', () => { + const dto: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100] }, + { name: 'name', type: FieldType.string, values: ['a', 'b'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const frame = new MutableDataFrame(dto); + expect(frame.fields[0].values.toArray()).toEqual([100, undefined, undefined]); + + // Set a value on the second row + frame.set(1, { time: 200, name: 'BB', value: 20 }); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1 }, // 1 + { time: 200, name: 'BB', value: 20 }, // 2 + { time: undefined, name: undefined, value: 3 }, // 3 + ]); + + // Add a time value that has an array type + frame.add({ time: 300 }); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1 }, // 1 + { time: 200, name: 'BB', value: 20 }, // 2 + { time: undefined, name: undefined, value: 3 }, // 3 + { time: 300, name: undefined, value: undefined }, // 5 + ]); + + // Make sure length survives a spread operator + const keys = Object.keys(frame); + const copy = { ...frame } as any; + expect(keys).toContain('length'); + expect(copy.length).toEqual(frame.length); + }); +}); diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts new file mode 100644 index 0000000..a76873b --- /dev/null +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -0,0 +1,245 @@ +import { Field, DataFrame, DataFrameDTO, FieldDTO, FieldType } from '../types/dataFrame'; +import { QueryResultMeta } from '../types/data'; +import { guessFieldTypeFromValue, guessFieldTypeForField, toDataFrameDTO } from './processDataFrame'; +import { isString } from 'lodash'; +import { makeFieldParser } from '../utils/fieldParser'; +import { MutableVector, Vector } from '../types/vector'; +import { ArrayVector } from '../vector/ArrayVector'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +export type MutableField = Field>; + +type MutableVectorCreator = (buffer?: any[]) => MutableVector; + +export const MISSING_VALUE: any = undefined; // Treated as connected in new graph panel + +export class MutableDataFrame extends FunctionalVector implements DataFrame, MutableVector { + name?: string; + refId?: string; + meta?: QueryResultMeta; + fields: MutableField[] = []; + + private first: Vector = new ArrayVector(); + private creator: MutableVectorCreator; + + constructor(source?: DataFrame | DataFrameDTO, creator?: MutableVectorCreator) { + super(); + + // This creates the underlying storage buffers + this.creator = creator + ? creator + : (buffer?: any[]) => { + return new ArrayVector(buffer); + }; + + // Copy values from + if (source) { + const { name, refId, meta, fields } = source; + if (name) { + this.name = name; + } + if (refId) { + this.refId = refId; + } + if (meta) { + this.meta = meta; + } + if (fields) { + for (const f of fields) { + this.addField(f); + } + } + } + + // Get Length to show up if you use spread + Object.defineProperty(this, 'length', { + enumerable: true, + get: () => { + return this.first.length; + }, + }); + } + + // Defined for Vector interface + get length() { + return this.first.length; + } + + addFieldFor(value: any, name?: string): MutableField { + return this.addField({ + name: name || '', // Will be filled in + type: guessFieldTypeFromValue(value), + }); + } + + addField(f: Field | FieldDTO, startLength?: number): MutableField { + let buffer: any[] | undefined = undefined; + + if (f.values) { + if (Array.isArray(f.values)) { + buffer = f.values as any[]; + } else { + buffer = (f.values as Vector).toArray(); + } + } + + let type = f.type; + + if (!type && ('time' === f.name || 'Time' === f.name)) { + type = FieldType.time; + } else { + if (!type && buffer && buffer.length) { + type = guessFieldTypeFromValue(buffer[0]); + } + if (!type) { + type = FieldType.other; + } + } + + // Make sure it has a name + let name = f.name; + if (!name) { + name = `Field ${this.fields.length + 1}`; + } + + const field: MutableField = { + ...f, + name, + type, + config: f.config || {}, + values: this.creator(buffer), + }; + + if (type === FieldType.other) { + type = guessFieldTypeForField(field); + if (type) { + field.type = type; + } + } + + this.fields.push(field); + this.first = this.fields[0].values; + + // Make sure the field starts with a given length + if (startLength) { + while (field.values.length < startLength) { + field.values.add(MISSING_VALUE); + } + } else { + this.validate(); + } + + return field; + } + + validate() { + // Make sure all arrays are the same length + const length = this.fields.reduce((v: number, f) => { + return Math.max(v, f.values.length); + }, 0); + + // Add empty elements until everything matches + for (const field of this.fields) { + while (field.values.length !== length) { + field.values.add(MISSING_VALUE); + } + } + } + + /** + * Reverse all values + */ + reverse() { + for (const f of this.fields) { + f.values.reverse(); + } + } + + /** + * This will add each value to the corresponding column + */ + appendRow(row: any[]) { + // Add any extra columns + for (let i = this.fields.length; i < row.length; i++) { + this.addField({ + name: `Field ${i + 1}`, + type: guessFieldTypeFromValue(row[i]), + }); + } + + // The first line may change the field types + if (this.length < 1) { + for (let i = 0; i < this.fields.length; i++) { + const f = this.fields[i]; + if (!f.type || f.type === FieldType.other) { + f.type = guessFieldTypeFromValue(row[i]); + } + } + } + + for (let i = 0; i < this.fields.length; i++) { + const f = this.fields[i]; + let v = row[i]; + if (f.type !== FieldType.string && isString(v)) { + if (!f.parse) { + f.parse = makeFieldParser(v, f); + } + v = f.parse(v); + } + f.values.add(v); + } + } + + /** + * Add values from an object to corresponding fields. Similar to appendRow but does not create new fields. + */ + add(value: T) { + // Will add one value for every field + const obj = value as any; + for (const field of this.fields) { + let val = obj[field.name]; + + if (field.type !== FieldType.string && isString(val)) { + if (!field.parse) { + field.parse = makeFieldParser(val, field); + } + val = field.parse(val); + } + + if (val === undefined) { + val = MISSING_VALUE; + } + + field.values.add(val); + } + } + + set(index: number, value: T) { + if (index > this.length) { + throw new Error('Unable ot set value beyond current length'); + } + + const obj = (value as any) || {}; + for (const field of this.fields) { + field.values.set(index, obj[field.name]); + } + } + + /** + * Get an object with a property for each field in the DataFrame + */ + get(idx: number): T { + const v: any = {}; + for (const field of this.fields) { + v[field.name] = field.values.get(idx); + } + return v as T; + } + + /** + * The simplified JSON values used in JSON.stringify() + */ + toJSON() { + return toDataFrameDTO(this); + } +} diff --git a/packages/grafana-data/src/dataframe/StreamingDataFrame.test.ts b/packages/grafana-data/src/dataframe/StreamingDataFrame.test.ts new file mode 100644 index 0000000..6c97a3e --- /dev/null +++ b/packages/grafana-data/src/dataframe/StreamingDataFrame.test.ts @@ -0,0 +1,426 @@ +import { reduceField, ReducerID } from '..'; +import { DataFrame, FieldType } from '../types/dataFrame'; +import { DataFrameJSON } from './DataFrameJSON'; +import { StreamingDataFrame } from './StreamingDataFrame'; + +describe('Streaming JSON', () => { + describe('when called with a DataFrame', () => { + const json: DataFrameJSON = { + schema: { + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'name', type: FieldType.string }, + { name: 'value', type: FieldType.number }, + ], + }, + data: { + values: [ + [100, 200, 300], + ['a', 'b', 'c'], + [1, 2, 3], + ], + }, + }; + + const stream = new StreamingDataFrame(json, { + maxLength: 5, + maxDelta: 300, + }); + + it('should create frame with schema & data', () => { + expect(stream.fields.map((f) => ({ name: f.name, value: f.values.buffer }))).toMatchInlineSnapshot(` + Array [ + Object { + "name": "time", + "value": Array [ + 100, + 200, + 300, + ], + }, + Object { + "name": "name", + "value": Array [ + "a", + "b", + "c", + ], + }, + Object { + "name": "value", + "value": Array [ + 1, + 2, + 3, + ], + }, + ] + `); + }); + + it('should append new data to frame', () => { + stream.push({ + data: { + values: [[400], ['d'], [4]], + }, + }); + + expect(stream.fields.map((f) => ({ name: f.name, value: f.values.buffer }))).toMatchInlineSnapshot(` + Array [ + Object { + "name": "time", + "value": Array [ + 100, + 200, + 300, + 400, + ], + }, + Object { + "name": "name", + "value": Array [ + "a", + "b", + "c", + "d", + ], + }, + Object { + "name": "value", + "value": Array [ + 1, + 2, + 3, + 4, + ], + }, + ] + `); + }); + + it('should append new data and slice based on maxDelta', () => { + stream.push({ + data: { + values: [[500], ['e'], [5]], + }, + }); + + expect(stream.fields.map((f) => ({ name: f.name, value: f.values.buffer }))).toMatchInlineSnapshot(` + Array [ + Object { + "name": "time", + "value": Array [ + 200, + 300, + 400, + 500, + ], + }, + Object { + "name": "name", + "value": Array [ + "b", + "c", + "d", + "e", + ], + }, + Object { + "name": "value", + "value": Array [ + 2, + 3, + 4, + 5, + ], + }, + ] + `); + }); + + it('should append new data and slice based on maxLength', () => { + stream.push({ + data: { + values: [ + [501, 502, 503], + ['f', 'g', 'h'], + [6, 7, 8, 9], + ], + }, + }); + + expect(stream.fields.map((f) => ({ name: f.name, value: f.values.buffer }))).toMatchInlineSnapshot(` + Array [ + Object { + "name": "time", + "value": Array [ + 400, + 500, + 501, + 502, + 503, + ], + }, + Object { + "name": "name", + "value": Array [ + "d", + "e", + "f", + "g", + "h", + ], + }, + Object { + "name": "value", + "value": Array [ + 4, + 5, + 6, + 7, + 8, + 9, + ], + }, + ] + `); + }); + }); + + describe('lengths property is accurate', () => { + const stream = new StreamingDataFrame( + { + schema: { + fields: [{ name: 'simple', type: FieldType.number }], + }, + data: { + values: [[100]], + }, + }, + { + maxLength: 5, + } + ); + let val = reduceField({ field: stream.fields[0], reducers: [ReducerID.lastNotNull] })[ReducerID.lastNotNull]; + expect(val).toEqual(100); + expect(stream.length).toEqual(1); + stream.push({ + data: { values: [[200]] }, + }); + val = reduceField({ field: stream.fields[0], reducers: [ReducerID.lastNotNull] })[ReducerID.lastNotNull]; + expect(val).toEqual(200); + expect(stream.length).toEqual(2); + + const copy = ({ ...stream } as any) as DataFrame; + expect(copy.length).toEqual(2); + }); + + describe('streaming labels column', () => { + const stream = new StreamingDataFrame( + { + schema: { + fields: [ + { name: 'labels', type: FieldType.string }, + { name: 'time', type: FieldType.time }, + { name: 'speed', type: FieldType.number }, + { name: 'light', type: FieldType.number }, + ], + }, + }, + { + maxLength: 4, + } + ); + + stream.push({ + data: { + values: [ + ['sensor=A', 'sensor=B'], + [100, 100], + [10, 15], + [1, 2], + ], + }, + }); + + stream.push({ + data: { + values: [ + ['sensor=B', 'sensor=C'], + [200, 200], + [20, 25], + [3, 4], + ], + }, + }); + + stream.push({ + data: { + values: [ + ['sensor=A', 'sensor=C'], + [300, 400], + [30, 40], + [5, 6], + ], + }, + }); + + expect(stream.fields.map((f) => ({ name: f.name, labels: f.labels, values: f.values.buffer }))) + .toMatchInlineSnapshot(` + Array [ + Object { + "labels": undefined, + "name": "time", + "values": Array [ + 100, + 200, + 300, + 400, + ], + }, + Object { + "labels": Object { + "sensor": "A", + }, + "name": "speed", + "values": Array [ + 10, + undefined, + 30, + undefined, + ], + }, + Object { + "labels": Object { + "sensor": "A", + }, + "name": "light", + "values": Array [ + 1, + undefined, + 5, + undefined, + ], + }, + Object { + "labels": Object { + "sensor": "B", + }, + "name": "speed", + "values": Array [ + 15, + 20, + undefined, + undefined, + ], + }, + Object { + "labels": Object { + "sensor": "B", + }, + "name": "light", + "values": Array [ + 2, + 3, + undefined, + undefined, + ], + }, + Object { + "labels": Object { + "sensor": "C", + }, + "name": "speed", + "values": Array [ + undefined, + 25, + undefined, + 40, + ], + }, + Object { + "labels": Object { + "sensor": "C", + }, + "name": "light", + "values": Array [ + undefined, + 4, + undefined, + 6, + ], + }, + ] + `); + }); + + /* + describe('transpose vertical records', () => { + let vrecsA = [ + ['sensor=A', 'sensor=B'], + [100, 100], + [10, 15], + ]; + + let vrecsB = [ + ['sensor=B', 'sensor=C'], + [200, 200], + [20, 25], + ]; + + let vrecsC = [ + ['sensor=A', 'sensor=C'], + [300, 400], + [30, 40], + ]; + + let cTables = transpose(vrecsC); + + expect(cTables).toMatchInlineSnapshot(` + Array [ + Array [ + "sensor=A", + "sensor=C", + ], + Array [ + Array [ + Array [ + 300, + ], + Array [ + 30, + ], + ], + Array [ + Array [ + 400, + ], + Array [ + 40, + ], + ], + ], + ] + `); + + let cJoined = join(cTables[1]); + + expect(cJoined).toMatchInlineSnapshot(` + Array [ + Array [ + 300, + 400, + ], + Array [ + 30, + undefined, + ], + Array [ + undefined, + 40, + ], + ] + `); + }); +*/ +}); diff --git a/packages/grafana-data/src/dataframe/StreamingDataFrame.ts b/packages/grafana-data/src/dataframe/StreamingDataFrame.ts new file mode 100644 index 0000000..d944aff --- /dev/null +++ b/packages/grafana-data/src/dataframe/StreamingDataFrame.ts @@ -0,0 +1,288 @@ +import { Field, DataFrame, FieldType } from '../types/dataFrame'; +import { Labels, QueryResultMeta } from '../types'; +import { ArrayVector } from '../vector'; +import { DataFrameJSON, decodeFieldValueEntities, FieldSchema } from './DataFrameJSON'; +import { guessFieldTypeFromValue } from './processDataFrame'; +import { join } from '../transformations/transformers/joinDataFrames'; +import { AlignedData } from 'uplot'; + +/** + * @alpha + */ +export interface StreamingFrameOptions { + maxLength?: number; // 1000 + maxDelta?: number; // how long to keep things +} + +enum PushMode { + wide, + labels, + // long +} + +/** + * Unlike a circular buffer, this will append and periodically slice the front + * + * @alpha + */ +export class StreamingDataFrame implements DataFrame { + name?: string; + refId?: string; + meta?: QueryResultMeta; + + fields: Array>> = []; + length = 0; + + options: StreamingFrameOptions; + + private schemaFields: FieldSchema[] = []; + private timeFieldIndex = -1; + private pushMode = PushMode.wide; + + // current labels + private labels: Set = new Set(); + + constructor(frame: DataFrameJSON, opts?: StreamingFrameOptions) { + this.options = { + maxLength: 1000, + maxDelta: Infinity, + ...opts, + }; + + this.push(frame); + } + + /** + * apply the new message to the existing data. This will replace the existing schema + * if a new schema is included in the message, or append data matching the current schema + */ + push(msg: DataFrameJSON) { + const { schema, data } = msg; + + if (schema) { + this.pushMode = PushMode.wide; + this.timeFieldIndex = schema.fields.findIndex((f) => f.type === FieldType.time); + if ( + this.timeFieldIndex === 1 && + schema.fields[0].name === 'labels' && + schema.fields[0].type === FieldType.string + ) { + this.pushMode = PushMode.labels; + this.timeFieldIndex = 0; // after labels are removed! + } + + const niceSchemaFields = this.pushMode === PushMode.labels ? schema.fields.slice(1) : schema.fields; + + // create new fields from the schema + const newFields = niceSchemaFields.map((f, idx) => { + return { + config: f.config ?? {}, + name: f.name, + labels: f.labels, + type: f.type ?? FieldType.other, + // transfer old values by type & name, unless we relied on labels to match fields + values: + this.pushMode === PushMode.wide + ? this.fields.find((of) => of.name === f.name && f.type === of.type)?.values ?? new ArrayVector() + : new ArrayVector(), + }; + }); + + this.name = schema.name; + this.refId = schema.refId; + this.meta = schema.meta; + this.schemaFields = niceSchemaFields; + this.fields = newFields; + } + + if (data && data.values.length && data.values[0].length) { + let { values, entities } = data; + + if (entities) { + entities.forEach((ents, i) => { + if (ents) { + decodeFieldValueEntities(ents, values[i]); + // TODO: append replacements to field + } + }); + } + + if (this.pushMode === PushMode.labels) { + // augment and transform data to match current schema for standard circPush() path + const labeledTables = transpose(values); + + // make sure fields are initalized for each label + for (const label of labeledTables.keys()) { + if (!this.labels.has(label)) { + this.addLabel(label); + } + } + + // TODO: cache higher up + let dummyTable = Array(this.schemaFields.length).fill([]); + + let tables: AlignedData[] = []; + this.labels.forEach((label) => { + tables.push(labeledTables.get(label) ?? dummyTable); + }); + + values = join(tables); + } + + if (values.length !== this.fields.length) { + if (this.fields.length) { + throw new Error(`push message mismatch. Expected: ${this.fields.length}, recieved: ${values.length}`); + } + + this.fields = values.map((vals, idx) => { + let name = `Field ${idx}`; + let type = guessFieldTypeFromValue(vals[0]); + const isTime = idx === 0 && type === FieldType.number && vals[0] > 1600016688632; + if (isTime) { + type = FieldType.time; + name = 'Time'; + } + + return { + name, + type, + config: {}, + values: new ArrayVector([]), + }; + }); + } + + let curValues = this.fields.map((f) => f.values.buffer); + + let appended = circPush(curValues, values, this.options.maxLength, this.timeFieldIndex, this.options.maxDelta); + + appended.forEach((v, i) => { + const { state, values } = this.fields[i]; + values.buffer = v; + if (state) { + state.calcs = undefined; + } + }); + + // Update the frame length + this.length = appended[0].length; + } + } + + // adds a set of fields for a new label + private addLabel(label: string) { + let labelCount = this.labels.size; + + // parse labels + const parsedLabels: Labels = {}; + + label.split(',').forEach((kv) => { + const [key, val] = kv.trim().split('='); + parsedLabels[key] = val; + }); + + if (labelCount === 0) { + // mutate existing fields and add labels + this.fields.forEach((f, i) => { + if (i > 0) { + f.labels = parsedLabels; + } + }); + } else { + for (let i = 1; i < this.schemaFields.length; i++) { + let proto = this.schemaFields[i] as Field; + + this.fields.push({ + ...proto, + config: proto.config ?? {}, + labels: parsedLabels, + values: new ArrayVector(Array(this.length).fill(undefined)), + }); + } + } + + this.labels.add(label); + } +} + +// converts vertical insertion records with table keys in [0] and column values in [1...N] +// to join()-able tables with column arrays +export function transpose(vrecs: any[][]) { + let tableKeys = new Set(vrecs[0]); + let tables = new Map(); + + tableKeys.forEach((key) => { + let cols = Array(vrecs.length - 1) + .fill(null) + .map(() => []); + + tables.set(key, cols); + }); + + for (let r = 0; r < vrecs[0].length; r++) { + let table = tables.get(vrecs[0][r]); + for (let c = 1; c < vrecs.length; c++) { + table[c - 1].push(vrecs[c][r]); + } + } + + return tables; +} + +// binary search for index of closest value +function closestIdx(num: number, arr: number[], lo?: number, hi?: number) { + let mid; + lo = lo || 0; + hi = hi || arr.length - 1; + let bitwise = hi <= 2147483647; + + while (hi - lo > 1) { + mid = bitwise ? (lo + hi) >> 1 : Math.floor((lo + hi) / 2); + + if (arr[mid] < num) { + lo = mid; + } else { + hi = mid; + } + } + + if (num - arr[lo] <= arr[hi] - num) { + return lo; + } + + return hi; +} + +// mutable circular push +function circPush(data: number[][], newData: number[][], maxLength = Infinity, deltaIdx = 0, maxDelta = Infinity) { + for (let i = 0; i < data.length; i++) { + data[i] = data[i].concat(newData[i]); + } + + const nlen = data[0].length; + + let sliceIdx = 0; + + if (nlen > maxLength) { + sliceIdx = nlen - maxLength; + } + + if (maxDelta !== Infinity && deltaIdx >= 0) { + const deltaLookup = data[deltaIdx]; + + const low = deltaLookup[sliceIdx]; + const high = deltaLookup[nlen - 1]; + + if (high - low > maxDelta) { + sliceIdx = closestIdx(high - maxDelta, deltaLookup, sliceIdx); + } + } + + if (sliceIdx) { + for (let i = 0; i < data.length; i++) { + data[i] = data[i].slice(sliceIdx); + } + } + + return data; +} diff --git a/packages/grafana-data/src/dataframe/__snapshots__/all_types.golden.arrow b/packages/grafana-data/src/dataframe/__snapshots__/all_types.golden.arrow new file mode 100644 index 0000000..0451405 Binary files /dev/null and b/packages/grafana-data/src/dataframe/__snapshots__/all_types.golden.arrow differ diff --git a/packages/grafana-data/src/dataframe/dimensions.ts b/packages/grafana-data/src/dataframe/dimensions.ts new file mode 100644 index 0000000..e3b1455 --- /dev/null +++ b/packages/grafana-data/src/dataframe/dimensions.ts @@ -0,0 +1,39 @@ +import { Field } from '../types/dataFrame'; +import { KeyValue } from '../types/data'; + +export interface Dimension { + // Name of the dimension + name: string; + // Collection of fields representing dimension + // I.e. in 2d graph we have two dimension- X and Y axes. Both dimensions can represent + // multiple fields being drawn on the graph. + // For instance y-axis dimension is a collection of series value fields, + // and x-axis dimension is a collection of corresponding time fields + columns: Array>; +} + +export type Dimensions = KeyValue; + +export const createDimension = (name: string, columns: Field[]): Dimension => { + return { + name, + columns, + }; +}; + +export const getColumnsFromDimension = (dimension: Dimension) => { + return dimension.columns; +}; +export const getColumnFromDimension = (dimension: Dimension, column: number) => { + return dimension.columns[column]; +}; + +export const getValueFromDimension = (dimension: Dimension, column: number, row: number) => { + return dimension.columns[column].values.get(row); +}; + +export const getAllValuesFromDimension = (dimension: Dimension, column: number, row: number) => { + return dimension.columns.map((c) => c.values.get(row)); +}; + +export const getDimensionByName = (dimensions: Dimensions, name: string) => dimensions[name]; diff --git a/packages/grafana-data/src/dataframe/frameComparisons.test.ts b/packages/grafana-data/src/dataframe/frameComparisons.test.ts new file mode 100644 index 0000000..efd3e24 --- /dev/null +++ b/packages/grafana-data/src/dataframe/frameComparisons.test.ts @@ -0,0 +1,223 @@ +import { FieldType } from '../types/dataFrame'; +import { compareDataFrameStructures, compareArrayValues } from './frameComparisons'; +import { toDataFrame } from './processDataFrame'; + +describe('test comparisons', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const frameB = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { + name: 'value', + type: FieldType.number, + values: [1, 2, 3], + config: { + decimals: 4, + }, + labels: { server: 'A' }, + }, + ], + }); + const field0 = frameB.fields[0]; + const field1 = frameB.fields[1]; + + it('should support null/undefined without crash', () => { + expect(compareDataFrameStructures(frameA, frameA)).toBeTruthy(); + expect(compareDataFrameStructures(frameA, { ...frameA })).toBeTruthy(); + expect(compareDataFrameStructures(frameA, frameB)).toBeFalsy(); + expect(compareDataFrameStructures(frameA, null as any)).toBeFalsy(); + expect(compareDataFrameStructures(undefined as any, frameA)).toBeFalsy(); + + expect(compareArrayValues([frameA], [frameA], compareDataFrameStructures)).toBeTruthy(); + expect(compareArrayValues([frameA], null as any, compareDataFrameStructures)).toBeFalsy(); + expect(compareArrayValues(null as any, [frameA], compareDataFrameStructures)).toBeFalsy(); + }); + + it('name change should be a structure change', () => { + expect(compareDataFrameStructures(frameB, { ...frameB, name: 'AA' })).toBeFalsy(); + }); + + it('label change should be a structure change', () => { + const changedFrameB = { + ...frameB, + fields: [ + frameB.fields[0], + { + ...frameB.fields[1], + labels: { server: 'B' }, + }, + ], + }; + expect(compareDataFrameStructures(frameB, changedFrameB)).toBeFalsy(); + }); + + it('Field copy should not be a structure change', () => { + expect(compareDataFrameStructures(frameB, { ...frameB, fields: [field0, field1] })).toBeTruthy(); + }); + + it('changing type should change the config', () => { + expect( + compareDataFrameStructures(frameB, { + ...frameB, + fields: [ + field0, + { + ...field1, + type: FieldType.trace, // Change the type + }, + ], + }) + ).toBeFalsy(); + }); + + it('full copy of config will not change structure', () => { + expect( + compareDataFrameStructures(frameB, { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + ...field1.config, // no change + }, + }, + ], + }) + ).toBeTruthy(); // no change + }); + + it('adding an additional config field', () => { + expect( + compareDataFrameStructures(frameB, { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + ...field1.config, + unit: 'rpm', + }, + }, + ], + }) + ).toBeFalsy(); + }); + + describe('custom config comparison', () => { + it('handles custom config shallow equality', () => { + const a = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 1, + b: 'test', + }, + }, + }, + ], + }; + + const b = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 1, + b: 'test', + }, + }, + }, + ], + }; + + expect(compareDataFrameStructures(a, b)).toBeTruthy(); + }); + + it('handles custom config shallow inequality', () => { + const a = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 1, + }, + }, + }, + ], + }; + + const b = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 2, + }, + }, + }, + ], + }; + + expect(compareDataFrameStructures(a, b)).toBeFalsy(); + }); + + it('does not compare deeply', () => { + const a = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: { + b: 1, + }, + }, + }, + }, + ], + }; + + const b = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: { + b: 1, + }, + }, + }, + }, + ], + }; + + expect(compareDataFrameStructures(a, b)).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/frameComparisons.ts b/packages/grafana-data/src/dataframe/frameComparisons.ts new file mode 100644 index 0000000..218b969 --- /dev/null +++ b/packages/grafana-data/src/dataframe/frameComparisons.ts @@ -0,0 +1,125 @@ +import { DataFrame } from '../types/dataFrame'; + +/** + * Returns true if both frames have the same name, fields, labels and configs. + * + * @example + * To compare multiple frames use: + * ``` + * compareArrayValues(a, b, framesHaveSameStructure); + * ``` + * NOTE: this does a shallow check on the FieldConfig properties, when using the query + * editor, this should be sufficient, however if applicaitons are mutating properties + * deep in the FieldConfig this will not recognize a change + * + * @beta + */ +export function compareDataFrameStructures(a: DataFrame, b: DataFrame, skipConfig?: boolean): boolean { + if (a === b) { + return true; + } + + if (a?.fields?.length !== b?.fields?.length) { + return false; + } + + if (a.name !== b.name) { + return false; + } + + for (let i = 0; i < a.fields.length; i++) { + const fA = a.fields[i]; + const fB = b.fields[i]; + + if (fA.type !== fB.type || fA.name !== fB.name) { + return false; + } + + // Do not check the config fields + if (skipConfig) { + continue; + } + + // Check if labels are different + if (fA.labels && fB.labels && !shallowCompare(fA.labels, fB.labels)) { + return false; + } + + const cfgA = fA.config as any; + const cfgB = fB.config as any; + + let aKeys = Object.keys(cfgA); + let bKeys = Object.keys(cfgB); + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (const key of aKeys) { + if (!(key in cfgB)) { + return false; + } + + if (key === 'custom') { + if (!shallowCompare(cfgA[key], cfgB[key])) { + return false; + } + } else if (cfgA[key] !== cfgB[key]) { + return false; + } + } + } + + return true; +} + +/** + * Check if all values in two arrays match the compare funciton + * + * @beta + */ +export function compareArrayValues(a: T[], b: T[], cmp: (a: T, b: T) => boolean) { + if (a === b) { + return true; + } + if (a?.length !== b?.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!cmp(a[i], b[i])) { + return false; + } + } + return true; +} + +type Cmp = (valA: any, valB: any) => boolean; + +const defaultCmp: Cmp = (a, b) => a === b; + +/** + * Checks if two objects are equal shallowly + * + * @beta + */ +export function shallowCompare(a: T, b: T, cmp: Cmp = defaultCmp) { + if (a === b) { + return true; + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (let key of aKeys) { + //@ts-ignore + if (!cmp(a[key], b[key])) { + return false; + } + } + + return true; +} diff --git a/packages/grafana-data/src/dataframe/index.ts b/packages/grafana-data/src/dataframe/index.ts new file mode 100644 index 0000000..75caabf --- /dev/null +++ b/packages/grafana-data/src/dataframe/index.ts @@ -0,0 +1,11 @@ +export * from './DataFrameView'; +export * from './FieldCache'; +export * from './CircularDataFrame'; +export * from './MutableDataFrame'; +export * from './processDataFrame'; +export * from './dimensions'; +export * from './ArrayDataFrame'; +export * from './DataFrameJSON'; +export { StreamingDataFrame, StreamingFrameOptions } from './StreamingDataFrame'; +export * from './frameComparisons'; +export { anySeriesWithTimeField } from './utils'; diff --git a/packages/grafana-data/src/dataframe/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts new file mode 100644 index 0000000..7de45a2 --- /dev/null +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -0,0 +1,336 @@ +import { + guessFieldTypeFromValue, + guessFieldTypes, + isDataFrame, + isTableData, + sortDataFrame, + toDataFrame, + toLegacyResponseData, +} from './processDataFrame'; +import { DataFrameDTO, FieldType, TableData, TimeSeries } from '../types/index'; +import { dateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; +import { ArrayDataFrame } from './ArrayDataFrame'; + +describe('toDataFrame', () => { + it('converts timeseries to series', () => { + const input1 = { + target: 'Field Name', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + let series = toDataFrame(input1); + expect(series.name).toBe(input1.target); + expect(series.fields[1].name).toBe('Value'); + + const v0 = series.fields[0].values; + const v1 = series.fields[1].values; + expect(v0.length).toEqual(2); + expect(v0.get(0)).toEqual(1); + expect(v0.get(1)).toEqual(2); + + expect(v1.length).toEqual(2); + expect(v1.get(0)).toEqual(100); + expect(v1.get(1)).toEqual(200); + + // Should fill a default name if target is empty + const input2 = { + // without target + target: '', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + series = toDataFrame(input2); + expect(series.fields[1].name).toEqual('Value'); + }); + + it('assumes TimeSeries values are numbers', () => { + const input1 = { + target: 'time', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + const data = toDataFrame(input1); + expect(data.fields[0].type).toBe(FieldType.time); + expect(data.fields[1].type).toBe(FieldType.number); + }); + + it('keeps dataFrame unchanged', () => { + const input = toDataFrame({ + datapoints: [ + [100, 1], + [200, 2], + ], + }); + expect(input.length).toEqual(2); + + // If the object is already a DataFrame, it should not change + const again = toDataFrame(input); + expect(again).toBe(input); + }); + + it('Make sure ArrayDataFrame is used as a DataFrame without modification', () => { + const orig = [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ]; + const array = new ArrayDataFrame(orig); + const frame = toDataFrame(array); + expect(frame).toEqual(array); + expect(frame instanceof ArrayDataFrame).toEqual(true); + expect(frame.length).toEqual(orig.length); + expect(frame.fields.map((f) => f.name)).toEqual(['a', 'b']); + }); + + it('throws when table rows is not array', () => { + expect(() => + toDataFrame({ + columns: [], + rows: {}, + }) + ).toThrowError('Expected table rows to be array, got object.'); + }); + + it('Guess Column Types from value', () => { + expect(guessFieldTypeFromValue(1)).toBe(FieldType.number); + expect(guessFieldTypeFromValue(1.234)).toBe(FieldType.number); + expect(guessFieldTypeFromValue(3.125e7)).toBe(FieldType.number); + expect(guessFieldTypeFromValue(true)).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue(false)).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue(new Date())).toBe(FieldType.time); + expect(guessFieldTypeFromValue(dateTime())).toBe(FieldType.time); + }); + + it('Guess Column Types from strings', () => { + expect(guessFieldTypeFromValue('1')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('1.234')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('NaN')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('3.125e7')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('True')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('FALSE')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('true')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('xxxx')).toBe(FieldType.string); + }); + + it('Guess Column Types from strings', () => { + expect(guessFieldTypeFromValue('1')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('1.234')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('NaN')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('3.125e7')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('True')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('FALSE')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('true')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('xxxx')).toBe(FieldType.string); + }); + + it('Guess Column Types from series', () => { + const series = new MutableDataFrame({ + fields: [ + { name: 'A (number)', values: [123, null] }, + { name: 'B (strings)', values: [null, 'Hello'] }, + { name: 'C (nulls)', values: [null, null] }, + { name: 'Time', values: ['2000', 1967] }, + { name: 'D (number strings)', values: ['NaN', null, 1] }, + ], + }); + const norm = guessFieldTypes(series); + expect(norm.fields[0].type).toBe(FieldType.number); + expect(norm.fields[1].type).toBe(FieldType.string); + expect(norm.fields[2].type).toBe(FieldType.other); + expect(norm.fields[3].type).toBe(FieldType.time); // based on name + expect(norm.fields[4].type).toBe(FieldType.number); + }); + + it('converts JSON document data to series', () => { + const input1 = { + datapoints: [ + { + _id: 'W5rvjW0BKe0cA-E1aHvr', + _type: '_doc', + _index: 'logs-2019.10.02', + '@message': 'Deployed website', + '@timestamp': [1570044340458], + tags: ['deploy', 'website-01'], + description: 'Torkel deployed website', + coordinates: { latitude: 12, longitude: 121, level: { depth: 3, coolness: 'very' } }, + 'unescaped-content': 'breaking
the
row', + }, + ], + filterable: true, + target: 'docs', + total: 206, + type: 'docs', + }; + const dataFrame = toDataFrame(input1); + expect(dataFrame.fields[0].name).toBe(input1.target); + + const v0 = dataFrame.fields[0].values; + expect(v0.length).toEqual(1); + expect(v0.get(0)).toEqual(input1.datapoints[0]); + }); +}); + +describe('SeriesData backwards compatibility', () => { + it('can convert TimeSeries to series and back again', () => { + const timeseries = { + target: 'Field Name', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); + + const roundtrip = toLegacyResponseData(series) as TimeSeries; + expect(isDataFrame(roundtrip)).toBeFalsy(); + expect(roundtrip.target).toBe(timeseries.target); + }); + + it('can convert TimeSeries to series and back again with tags should render name with tags', () => { + const timeseries = { + target: 'Series A', + tags: { server: 'ServerA', job: 'app' }, + datapoints: [ + [100, 1], + [200, 2], + ], + }; + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); + + const roundtrip = toLegacyResponseData(series) as TimeSeries; + expect(isDataFrame(roundtrip)).toBeFalsy(); + expect(roundtrip.target).toBe('{job="app", server="ServerA"}'); + }); + + it('can convert empty table to DataFrame then back to legacy', () => { + const table = { + columns: [], + rows: [], + type: 'table', + }; + + const series = toDataFrame(table); + const roundtrip = toLegacyResponseData(series) as TableData; + expect(roundtrip.columns.length).toBe(0); + expect(roundtrip.type).toBe('table'); + }); + + it('converts TableData to series and back again', () => { + const table = { + columns: [ + { text: 'a', unit: 'ms' }, + { text: 'b', unit: 'zz' }, + { text: 'c', unit: 'yy' }, + ], + rows: [ + [100, 1, 'a'], + [200, 2, 'a'], + ], + }; + const series = toDataFrame(table); + expect(isTableData(table)).toBeTruthy(); + expect(isDataFrame(series)).toBeTruthy(); + expect(series.fields[0].config.unit).toEqual('ms'); + + const roundtrip = toLegacyResponseData(series) as TimeSeries; + expect(isTableData(roundtrip)).toBeTruthy(); + expect(roundtrip).toMatchObject(table); + }); + + it('can convert empty TableData to DataFrame', () => { + const table = { + columns: [], + rows: [], + }; + + const series = toDataFrame(table); + expect(series.fields.length).toBe(0); + }); + + it('can convert DataFrame to TableData to series and back again', () => { + const json: DataFrameDTO = { + refId: 'Z', + meta: { + custom: { + something: 8, + }, + }, + fields: [ + { name: 'T', type: FieldType.time, values: [1, 2, 3] }, + { name: 'N', type: FieldType.number, config: { filterable: true }, values: [100, 200, 300] }, + { name: 'S', type: FieldType.string, config: { filterable: true }, values: ['1', '2', '3'] }, + ], + }; + const series = toDataFrame(json); + const table = toLegacyResponseData(series) as TableData; + expect(table.refId).toBe(series.refId); + expect(table.meta).toEqual(series.meta); + + const names = table.columns.map((c) => c.text); + expect(names).toEqual(['T', 'N', 'S']); + }); + + it('can convert TimeSeries to JSON document and back again', () => { + const timeseries = { + datapoints: [ + { + _id: 'W5rvjW0BKe0cA-E1aHvr', + _type: '_doc', + _index: 'logs-2019.10.02', + '@message': 'Deployed website', + '@timestamp': [1570044340458], + tags: ['deploy', 'website-01'], + description: 'Torkel deployed website', + coordinates: { latitude: 12, longitude: 121, level: { depth: 3, coolness: 'very' } }, + 'unescaped-content': 'breaking
the
row', + }, + ], + filterable: true, + target: 'docs', + total: 206, + type: 'docs', + }; + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); + + const roundtrip = toLegacyResponseData(series) as any; + expect(isDataFrame(roundtrip)).toBeFalsy(); + expect(roundtrip.type).toBe('docs'); + expect(roundtrip.target).toBe('docs'); + expect(roundtrip.filterable).toBeTruthy(); + }); +}); + +describe('sorted DataFrame', () => { + const frame = toDataFrame({ + fields: [ + { name: 'fist', type: FieldType.time, values: [1, 2, 3] }, + { name: 'second', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'third', type: FieldType.number, values: [2000, 3000, 1000] }, + ], + }); + it('Should sort numbers', () => { + const sorted = sortDataFrame(frame, 0, true); + expect(sorted.length).toEqual(3); + expect(sorted.fields[0].values.toArray()).toEqual([3, 2, 1]); + expect(sorted.fields[1].values.toArray()).toEqual(['c', 'b', 'a']); + }); + + it('Should sort strings', () => { + const sorted = sortDataFrame(frame, 1, true); + expect(sorted.length).toEqual(3); + expect(sorted.fields[0].values.toArray()).toEqual([3, 2, 1]); + expect(sorted.fields[1].values.toArray()).toEqual(['c', 'b', 'a']); + }); +}); diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts new file mode 100644 index 0000000..33fe81a --- /dev/null +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -0,0 +1,479 @@ +// Libraries +import { isArray, isBoolean, isNumber, isString } from 'lodash'; + +// Types +import { + DataFrame, + Field, + FieldConfig, + TimeSeries, + FieldType, + TableData, + Column, + GraphSeriesXY, + TimeSeriesValue, + FieldDTO, + DataFrameDTO, + TIME_SERIES_VALUE_FIELD_NAME, + TIME_SERIES_TIME_FIELD_NAME, +} from '../types/index'; +import { isDateTime } from '../datetime/moment_wrapper'; +import { ArrayVector } from '../vector/ArrayVector'; +import { MutableDataFrame } from './MutableDataFrame'; +import { SortedVector } from '../vector/SortedVector'; +import { ArrayDataFrame } from './ArrayDataFrame'; +import { getFieldDisplayName } from '../field/fieldState'; +import { fieldIndexComparer } from '../field/fieldComparers'; +import { vectorToArray } from '../vector/vectorToArray'; + +function convertTableToDataFrame(table: TableData): DataFrame { + const fields = table.columns.map((c) => { + // TODO: should be Column but type does not exists there so not sure whats up here. + const { text, type, ...disp } = c as any; + return { + name: text, // rename 'text' to the 'name' field + config: (disp || {}) as FieldConfig, + values: new ArrayVector(), + type: type && Object.values(FieldType).includes(type as FieldType) ? (type as FieldType) : FieldType.other, + }; + }); + + if (!isArray(table.rows)) { + throw new Error(`Expected table rows to be array, got ${typeof table.rows}.`); + } + + for (const row of table.rows) { + for (let i = 0; i < fields.length; i++) { + fields[i].values.buffer.push(row[i]); + } + } + + for (const f of fields) { + if (f.type === FieldType.other) { + const t = guessFieldTypeForField(f); + if (t) { + f.type = t; + } + } + } + + return { + fields, + refId: table.refId, + meta: table.meta, + name: table.name, + length: table.rows.length, + }; +} + +function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame { + const times: number[] = []; + const values: TimeSeriesValue[] = []; + + // Sometimes the points are sent as datapoints + const points = timeSeries.datapoints || (timeSeries as any).points; + for (const point of points) { + values.push(point[0]); + times.push(point[1] as number); + } + + const fields = [ + { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: {}, + values: new ArrayVector(times), + }, + { + name: TIME_SERIES_VALUE_FIELD_NAME, + type: FieldType.number, + config: { + unit: timeSeries.unit, + }, + values: new ArrayVector(values), + labels: timeSeries.tags, + }, + ]; + + if (timeSeries.title) { + (fields[1].config as FieldConfig).displayNameFromDS = timeSeries.title; + } + + return { + name: timeSeries.target || (timeSeries as any).name, + refId: timeSeries.refId, + meta: timeSeries.meta, + fields, + length: values.length, + }; +} + +/** + * This is added temporarily while we convert the LogsModel + * to DataFrame. See: https://github.com/grafana/grafana/issues/18528 + */ +function convertGraphSeriesToDataFrame(graphSeries: GraphSeriesXY): DataFrame { + const x = new ArrayVector(); + const y = new ArrayVector(); + + for (let i = 0; i < graphSeries.data.length; i++) { + const row = graphSeries.data[i]; + x.buffer.push(row[1]); + y.buffer.push(row[0]); + } + + return { + name: graphSeries.label, + fields: [ + { + name: graphSeries.label || TIME_SERIES_VALUE_FIELD_NAME, + type: FieldType.number, + config: {}, + values: x, + }, + { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: { + unit: 'dateTimeAsIso', + }, + values: y, + }, + ], + length: x.buffer.length, + }; +} + +function convertJSONDocumentDataToDataFrame(timeSeries: TimeSeries): DataFrame { + const fields = [ + { + name: timeSeries.target, + type: FieldType.other, + labels: timeSeries.tags, + config: { + unit: timeSeries.unit, + filterable: (timeSeries as any).filterable, + }, + values: new ArrayVector(), + }, + ]; + + for (const point of timeSeries.datapoints) { + fields[0].values.buffer.push(point); + } + + return { + name: timeSeries.target, + refId: timeSeries.target, + meta: { json: true }, + fields, + length: timeSeries.datapoints.length, + }; +} + +// PapaParse Dynamic Typing regex: +// https://github.com/mholt/PapaParse/blob/master/papaparse.js#L998 +const NUMBER = /^\s*(-?(\d*\.?\d+|\d+\.?\d*)(e[-+]?\d+)?|NAN)\s*$/i; + +/** + * Given a name and value, this will pick a reasonable field type + */ +export function guessFieldTypeFromNameAndValue(name: string, v: any): FieldType { + if (name) { + name = name.toLowerCase(); + if (name === 'date' || name === 'time') { + return FieldType.time; + } + } + return guessFieldTypeFromValue(v); +} + +/** + * Given a value this will guess the best column type + * + * TODO: better Date/Time support! Look for standard date strings? + */ +export function guessFieldTypeFromValue(v: any): FieldType { + if (v instanceof Date || isDateTime(v)) { + return FieldType.time; + } + + if (isNumber(v)) { + return FieldType.number; + } + + if (isString(v)) { + if (NUMBER.test(v)) { + return FieldType.number; + } + + if (v === 'true' || v === 'TRUE' || v === 'True' || v === 'false' || v === 'FALSE' || v === 'False') { + return FieldType.boolean; + } + + return FieldType.string; + } + + if (isBoolean(v)) { + return FieldType.boolean; + } + + return FieldType.other; +} + +/** + * Looks at the data to guess the column type. This ignores any existing setting + */ +export function guessFieldTypeForField(field: Field): FieldType | undefined { + // 1. Use the column name to guess + if (field.name) { + const name = field.name.toLowerCase(); + if (name === 'date' || name === 'time') { + return FieldType.time; + } + } + + // 2. Check the first non-null value + for (let i = 0; i < field.values.length; i++) { + const v = field.values.get(i); + if (v !== null) { + return guessFieldTypeFromValue(v); + } + } + + // Could not find anything + return undefined; +} + +/** + * @returns A copy of the series with the best guess for each field type. + * If the series already has field types defined, they will be used, unless `guessDefined` is true. + * @param series The DataFrame whose field's types should be guessed + * @param guessDefined Whether to guess types of fields with already defined types + */ +export const guessFieldTypes = (series: DataFrame, guessDefined = false): DataFrame => { + for (const field of series.fields) { + if (!field.type || field.type === FieldType.other || guessDefined) { + // Something is missing a type, return a modified copy + return { + ...series, + fields: series.fields.map((field) => { + if (field.type && field.type !== FieldType.other && !guessDefined) { + return field; + } + // Calculate a reasonable schema value + return { + ...field, + type: guessFieldTypeForField(field) || FieldType.other, + }; + }), + }; + } + } + // No changes necessary + return series; +}; + +export const isTableData = (data: any): data is DataFrame => data && data.hasOwnProperty('columns'); + +export const isDataFrame = (data: any): data is DataFrame => data && data.hasOwnProperty('fields'); + +/** + * Inspect any object and return the results as a DataFrame + */ +export function toDataFrame(data: any): DataFrame { + if ('fields' in data) { + // DataFrameDTO does not have length + if ('length' in data) { + return data as DataFrame; + } + + // This will convert the array values into Vectors + return new MutableDataFrame(data as DataFrameDTO); + } + + // Handle legacy docs/json type + if (data.hasOwnProperty('type') && data.type === 'docs') { + return convertJSONDocumentDataToDataFrame(data); + } + + if (data.hasOwnProperty('datapoints') || data.hasOwnProperty('points')) { + return convertTimeSeriesToDataFrame(data); + } + + if (data.hasOwnProperty('data')) { + return convertGraphSeriesToDataFrame(data); + } + + if (data.hasOwnProperty('columns')) { + return convertTableToDataFrame(data); + } + + if (Array.isArray(data)) { + return new ArrayDataFrame(data); + } + + console.warn('Can not convert', data); + throw new Error('Unsupported data format'); +} + +export const toLegacyResponseData = (frame: DataFrame): TimeSeries | TableData => { + const { fields } = frame; + + const rowCount = frame.length; + const rows: any[][] = []; + + if (fields.length === 2) { + const { timeField, timeIndex } = getTimeField(frame); + if (timeField) { + const valueIndex = timeIndex === 0 ? 1 : 0; + const valueField = fields[valueIndex]; + const timeField = fields[timeIndex!]; + + // Make sure it is [value,time] + for (let i = 0; i < rowCount; i++) { + rows.push([ + valueField.values.get(i), // value + timeField.values.get(i), // time + ]); + } + + return { + alias: frame.name, + target: getFieldDisplayName(valueField, frame), + datapoints: rows, + unit: fields[0].config ? fields[0].config.unit : undefined, + refId: frame.refId, + meta: frame.meta, + } as TimeSeries; + } + } + + for (let i = 0; i < rowCount; i++) { + const row: any[] = []; + for (let j = 0; j < fields.length; j++) { + row.push(fields[j].values.get(i)); + } + rows.push(row); + } + + if (frame.meta && frame.meta.json) { + return { + alias: fields[0].name || frame.name, + target: fields[0].name || frame.name, + datapoints: fields[0].values.toArray(), + filterable: fields[0].config ? fields[0].config.filterable : undefined, + type: 'docs', + } as TimeSeries; + } + + return { + columns: fields.map((f) => { + const { name, config } = f; + if (config) { + // keep unit etc + const { ...column } = config; + (column as Column).text = name; + return column as Column; + } + return { text: name }; + }), + type: 'table', + refId: frame.refId, + meta: frame.meta, + rows, + }; +}; + +export function sortDataFrame(data: DataFrame, sortIndex?: number, reverse = false): DataFrame { + const field = data.fields[sortIndex!]; + if (!field) { + return data; + } + + // Natural order + const index: number[] = []; + for (let i = 0; i < data.length; i++) { + index.push(i); + } + + const fieldComparer = fieldIndexComparer(field, reverse); + index.sort(fieldComparer); + + return { + ...data, + fields: data.fields.map((f) => { + return { + ...f, + values: new SortedVector(f.values, index), + }; + }), + }; +} + +/** + * Returns a copy with all values reversed + */ +export function reverseDataFrame(data: DataFrame): DataFrame { + return { + ...data, + fields: data.fields.map((f) => { + const copy = [...f.values.toArray()]; + copy.reverse(); + return { + ...f, + values: new ArrayVector(copy), + }; + }), + }; +} + +/** + * Wrapper to get an array from each field value + */ +export function getDataFrameRow(data: DataFrame, row: number): any[] { + const values: any[] = []; + for (const field of data.fields) { + values.push(field.values.get(row)); + } + return values; +} + +/** + * Returns a copy that does not include functions + */ +export function toDataFrameDTO(data: DataFrame): DataFrameDTO { + const fields: FieldDTO[] = data.fields.map((f) => { + let values = f.values.toArray(); + // The byte buffers serialize like objects + if (values instanceof Float64Array) { + values = vectorToArray(f.values); + } + return { + name: f.name, + type: f.type, + config: f.config, + values, + labels: f.labels, + }; + }); + + return { + fields, + refId: data.refId, + meta: data.meta, + name: data.name, + }; +} + +export const getTimeField = (series: DataFrame): { timeField?: Field; timeIndex?: number } => { + for (let i = 0; i < series.fields.length; i++) { + if (series.fields[i].type === FieldType.time) { + return { + timeField: series.fields[i], + timeIndex: i, + }; + } + } + return {}; +}; diff --git a/packages/grafana-data/src/dataframe/utils.test.ts b/packages/grafana-data/src/dataframe/utils.test.ts new file mode 100644 index 0000000..943a1d9 --- /dev/null +++ b/packages/grafana-data/src/dataframe/utils.test.ts @@ -0,0 +1,78 @@ +import { toDataFrame } from './processDataFrame'; +import { FieldType } from '../types'; +import { anySeriesWithTimeField } from './utils'; + +describe('anySeriesWithTimeField', () => { + describe('single frame', () => { + test('without time field', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + expect(anySeriesWithTimeField([frameA])).toBeFalsy(); + }); + + test('with time field', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + expect(anySeriesWithTimeField([frameA])).toBeTruthy(); + }); + }); + + describe('multiple frames', () => { + test('without time field', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const frameB = toDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }], + }); + expect(anySeriesWithTimeField([frameA, frameB])).toBeFalsy(); + }); + + test('with time field in any frame', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const frameB = toDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }], + }); + const frameC = toDataFrame({ + fields: [{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }], + }); + + expect(anySeriesWithTimeField([frameA, frameB, frameC])).toBeTruthy(); + }); + + test('with time field in a all frames', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const frameB = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + expect(anySeriesWithTimeField([frameA, frameB])).toBeTruthy(); + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/utils.ts b/packages/grafana-data/src/dataframe/utils.ts new file mode 100644 index 0000000..4044393 --- /dev/null +++ b/packages/grafana-data/src/dataframe/utils.ts @@ -0,0 +1,27 @@ +import { DataFrame, FieldType } from '../types/dataFrame'; +import { getTimeField } from './processDataFrame'; + +export function isTimeSerie(frame: DataFrame) { + if (frame.fields.length > 2) { + return false; + } + return Boolean(frame.fields.find((field) => field.type === FieldType.time)); +} + +export function isTimeSeries(data: DataFrame[]) { + return !data.find((frame) => !isTimeSerie(frame)); +} + +/** + * Indicates if there is any time field in the array of data frames + * @param data + */ +export function anySeriesWithTimeField(data: DataFrame[]) { + for (let i = 0; i < data.length; i++) { + const timeField = getTimeField(data[i]); + if (timeField.timeField !== undefined && timeField.timeIndex !== undefined) { + return true; + } + } + return false; +} diff --git a/packages/grafana-data/src/datetime/common.ts b/packages/grafana-data/src/datetime/common.ts new file mode 100644 index 0000000..fa7fb94 --- /dev/null +++ b/packages/grafana-data/src/datetime/common.ts @@ -0,0 +1,61 @@ +import { TimeZone, DefaultTimeZone } from '../types/time'; + +/** + * Used for helper functions handling time zones. + * + * @public + */ +export interface TimeZoneOptions { + /** + * Specify this if you want to override the timeZone used when parsing or formatting + * a date and time value. If no timeZone is set, the default timeZone for the current + * user is used. + */ + timeZone?: TimeZone; +} + +/** + * The type describing date and time options. Used for all the helper functions + * available to parse or format date and time values. + * + * @public + */ +export interface DateTimeOptions extends TimeZoneOptions { + /** + * Specify a {@link https://momentjs.com/docs/#/displaying/format | momentjs} format to + * use a custom formatting pattern or parsing pattern. If no format is set, + * then system configured default format is used. + */ + format?: string; +} + +/** + * The type to describe the time zone resolver function that will be used to access + * the default time zone of a user. + * + * @public + */ +export type TimeZoneResolver = () => TimeZone | undefined; + +let defaultTimeZoneResolver: TimeZoneResolver = () => DefaultTimeZone; + +/** + * Used by Grafana internals to set the {@link TimeZoneResolver} to access the current + * user timeZone. + * + * @internal + */ +export const setTimeZoneResolver = (resolver: TimeZoneResolver) => { + defaultTimeZoneResolver = resolver ?? defaultTimeZoneResolver; +}; + +/** + * Used to get the current selected time zone. If a valid time zone is passed in the + * options it will be returned. If no valid time zone is passed either the time zone + * configured for the user account will be returned or the default for Grafana. + * + * @public + */ +export const getTimeZone = (options?: T): TimeZone => { + return options?.timeZone ?? defaultTimeZoneResolver() ?? DefaultTimeZone; +}; diff --git a/packages/grafana-data/src/datetime/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts new file mode 100644 index 0000000..fe07179 --- /dev/null +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -0,0 +1,138 @@ +import sinon, { SinonFakeTimers } from 'sinon'; +import { each } from 'lodash'; + +import * as dateMath from './datemath'; +import { dateTime, DurationUnit, DateTime } from './moment_wrapper'; + +describe('DateMath', () => { + const spans: DurationUnit[] = ['s', 'm', 'h', 'd', 'w', 'M', 'y']; + const anchor = '2014-01-01T06:06:06.666Z'; + const unix = dateTime(anchor).valueOf(); + const format = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; + let clock: SinonFakeTimers; + + describe('errors', () => { + it('should return undefined if passed empty string', () => { + expect(dateMath.parse('')).toBe(undefined); + }); + + it('should return undefined if I pass an operator besides [+-/]', () => { + expect(dateMath.parse('now&1d')).toBe(undefined); + }); + + it('should return undefined if I pass a unit besides' + spans.toString(), () => { + expect(dateMath.parse('now+5f')).toBe(undefined); + }); + + it('should return undefined if rounding unit is not 1', () => { + expect(dateMath.parse('now/2y')).toBe(undefined); + expect(dateMath.parse('now/0.5y')).toBe(undefined); + }); + + it('should not go into an infinite loop when missing a unit', () => { + expect(dateMath.parse('now-0')).toBe(undefined); + expect(dateMath.parse('now-00')).toBe(undefined); + }); + }); + + it('now/d should set to start of current day', () => { + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + + const startOfDay = dateMath.parse('now/d', false)!.valueOf(); + expect(startOfDay).toBe(expected.getTime()); + }); + + it('now/d on a utc dashboard should be start of the current day in UTC time', () => { + const today = new Date(); + const expected = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate(), 0, 0, 0, 0)); + + const startOfDay = dateMath.parse('now/d', false, 'utc')!.valueOf(); + expect(startOfDay).toBe(expected.getTime()); + }); + + describe('subtraction', () => { + let now: DateTime; + let anchored: DateTime; + + beforeEach(() => { + clock = sinon.useFakeTimers(unix); + now = dateTime(); + anchored = dateTime(anchor); + }); + + each(spans, (span) => { + const nowEx = 'now-5' + span; + const thenEx = anchor + '||-5' + span; + + it('should return 5' + span + ' ago', () => { + expect(dateMath.parse(nowEx)!.format(format)).toEqual(now.subtract(5, span).format(format)); + }); + + it('should return 5' + span + ' before ' + anchor, () => { + expect(dateMath.parse(thenEx)!.format(format)).toEqual(anchored.subtract(5, span).format(format)); + }); + }); + + afterEach(() => { + clock.restore(); + }); + }); + + describe('rounding', () => { + let now: DateTime; + + beforeEach(() => { + clock = sinon.useFakeTimers(unix); + now = dateTime(); + }); + + each(spans, (span) => { + it('should round now to the beginning of the ' + span, () => { + expect(dateMath.parse('now/' + span)!.format(format)).toEqual(now.startOf(span).format(format)); + }); + + it('should round now to the end of the ' + span, () => { + expect(dateMath.parse('now/' + span, true)!.format(format)).toEqual(now.endOf(span).format(format)); + }); + }); + + afterEach(() => { + clock.restore(); + }); + }); + + describe('isValid', () => { + it('should return false when invalid date text', () => { + expect(dateMath.isValid('asd')).toBe(false); + }); + it('should return true when valid date text', () => { + expect(dateMath.isValid('now-1h')).toBe(true); + }); + }); + + describe('relative time to date parsing', () => { + it('should handle negative time', () => { + const date = dateMath.parseDateMath('-2d', dateTime([2014, 1, 5])); + expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); + }); + + it('should handle multiple math expressions', () => { + const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5])); + expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf()); + }); + + it('should return false when invalid expression', () => { + const date = dateMath.parseDateMath('2', dateTime([2014, 1, 5])); + expect(date).toEqual(undefined); + }); + + it('should strip whitespace from string', () => { + const date = dateMath.parseDateMath(' - 2d', dateTime([2014, 1, 5])); + expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/datemath.ts b/packages/grafana-data/src/datetime/datemath.ts new file mode 100644 index 0000000..d35e139 --- /dev/null +++ b/packages/grafana-data/src/datetime/datemath.ts @@ -0,0 +1,161 @@ +import { includes, isDate } from 'lodash'; +import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper'; +import { TimeZone } from '../types/index'; + +const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's']; + +export function isMathString(text: string | DateTime | Date): boolean { + if (!text) { + return false; + } + + if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) { + return true; + } else { + return false; + } +} + +/** + * Parses different types input to a moment instance. There is a specific formatting language that can be used + * if text arg is string. See unit tests for examples. + * @param text + * @param roundUp See parseDateMath function. + * @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used. + */ +export function parse( + text?: string | DateTime | Date | null, + roundUp?: boolean, + timezone?: TimeZone +): DateTime | undefined { + if (!text) { + return undefined; + } + + if (typeof text !== 'string') { + if (isDateTime(text)) { + return text; + } + if (isDate(text)) { + return dateTime(text); + } + // We got some non string which is not a moment nor Date. TS should be able to check for that but not always. + return undefined; + } else { + let time; + let mathString = ''; + let index; + let parseString; + + if (text.substring(0, 3) === 'now') { + time = dateTimeForTimeZone(timezone); + mathString = text.substring('now'.length); + } else { + index = text.indexOf('||'); + if (index === -1) { + parseString = text; + mathString = ''; // nothing else + } else { + parseString = text.substring(0, index); + mathString = text.substring(index + 2); + } + // We're going to just require ISO8601 timestamps, k? + time = dateTime(parseString, ISO_8601); + } + + if (!mathString.length) { + return time; + } + + return parseDateMath(mathString, time, roundUp); + } +} + +/** + * Checks if text is a valid date which in this context means that it is either a Moment instance or it can be parsed + * by parse function. See parse function to see what is considered acceptable. + * @param text + */ +export function isValid(text: string | DateTime): boolean { + const date = parse(text); + if (!date) { + return false; + } + + if (isDateTime(date)) { + return date.isValid(); + } + + return false; +} + +/** + * Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples. + * @param mathString + * @param time + * @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. + */ +// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any` +export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined { + const strippedMathString = mathString.replace(/\s/g, ''); + const dateTime = time; + let i = 0; + const len = strippedMathString.length; + + while (i < len) { + const c = strippedMathString.charAt(i++); + let type; + let num; + let unit; + + if (c === '/') { + type = 0; + } else if (c === '+') { + type = 1; + } else if (c === '-') { + type = 2; + } else { + return undefined; + } + + if (isNaN(parseInt(strippedMathString.charAt(i), 10))) { + num = 1; + } else if (strippedMathString.length === 2) { + num = strippedMathString.charAt(i); + } else { + const numFrom = i; + while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) { + i++; + if (i > 10) { + return undefined; + } + } + num = parseInt(strippedMathString.substring(numFrom, i), 10); + } + + if (type === 0) { + // rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M) + if (num !== 1) { + return undefined; + } + } + unit = strippedMathString.charAt(i++); + + if (!includes(units, unit)) { + return undefined; + } else { + if (type === 0) { + if (roundUp) { + dateTime.endOf(unit); + } else { + dateTime.startOf(unit); + } + } else if (type === 1) { + dateTime.add(num, unit); + } else if (type === 2) { + dateTime.subtract(num, unit); + } + } + } + return dateTime; +} diff --git a/packages/grafana-data/src/datetime/formats.test.ts b/packages/grafana-data/src/datetime/formats.test.ts new file mode 100644 index 0000000..9b551fa --- /dev/null +++ b/packages/grafana-data/src/datetime/formats.test.ts @@ -0,0 +1,19 @@ +import { localTimeFormat } from './formats'; + +describe('Date Formats', () => { + it('localTimeFormat', () => { + const format = localTimeFormat( + { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }, + '' + ); + + expect(format).toBe('MM/DD/YYYY, HH:mm:ss A'); + }); +}); diff --git a/packages/grafana-data/src/datetime/formats.ts b/packages/grafana-data/src/datetime/formats.ts new file mode 100644 index 0000000..f089a8e --- /dev/null +++ b/packages/grafana-data/src/datetime/formats.ts @@ -0,0 +1,121 @@ +export interface SystemDateFormatSettings { + fullDate: string; + interval: { + second: string; + minute: string; + hour: string; + day: string; + month: string; + year: string; + }; + useBrowserLocale: boolean; +} + +const DEFAULT_SYSTEM_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export class SystemDateFormatsState { + fullDate = DEFAULT_SYSTEM_DATE_FORMAT; + interval = { + second: 'HH:mm:ss', + minute: 'HH:mm', + hour: 'MM/DD HH:mm', + day: 'MM/DD', + month: 'YYYY-MM', + year: 'YYYY', + }; + + update(settings: SystemDateFormatSettings) { + this.fullDate = settings.fullDate; + this.interval = settings.interval; + + if (settings.useBrowserLocale) { + this.useBrowserLocale(); + } + } + + get fullDateMS() { + // Add millisecond to seconds part + return this.fullDate.replace('ss', 'ss.SSS'); + } + + useBrowserLocale() { + this.fullDate = localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + this.interval.second = localTimeFormat( + { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }, + null, + this.interval.second + ); + this.interval.minute = localTimeFormat( + { hour: '2-digit', minute: '2-digit', hour12: false }, + null, + this.interval.minute + ); + this.interval.hour = localTimeFormat( + { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }, + null, + this.interval.hour + ); + this.interval.day = localTimeFormat({ month: '2-digit', day: '2-digit', hour12: false }, null, this.interval.day); + this.interval.month = localTimeFormat( + { year: 'numeric', month: '2-digit', hour12: false }, + null, + this.interval.month + ); + } + + getTimeFieldUnit(useMsResolution?: boolean) { + return `time:${useMsResolution ? this.fullDateMS : this.fullDate}`; + } +} + +/** + * localTimeFormat helps to generate date formats for momentjs based on browser's locale + * + * @param locale browser locale, or default + * @param options DateTimeFormatOptions to format date + * @param fallback default format if Intl API is not present + */ +export function localTimeFormat( + options: Intl.DateTimeFormatOptions, + locale?: string | string[] | null, + fallback?: string +): string { + if (missingIntlDateTimeFormatSupport()) { + return fallback ?? DEFAULT_SYSTEM_DATE_FORMAT; + } + + if (!locale) { + locale = [...navigator.languages]; + } + + // https://momentjs.com/docs/#/displaying/format/ + const parts = new Intl.DateTimeFormat(locale, options).formatToParts(new Date()); + const mapping: { [key: string]: string } = { + year: 'YYYY', + month: 'MM', + day: 'DD', + hour: 'HH', + minute: 'mm', + second: 'ss', + weekday: 'ddd', + era: 'N', + dayPeriod: 'A', + timeZoneName: 'Z', + }; + + return parts.map((part) => mapping[part.type] || part.value).join(''); +} + +export const systemDateFormats = new SystemDateFormatsState(); + +const missingIntlDateTimeFormatSupport = (): boolean => { + return !('DateTimeFormat' in Intl) || !('formatToParts' in Intl.DateTimeFormat.prototype); +}; diff --git a/packages/grafana-data/src/datetime/formatter.test.ts b/packages/grafana-data/src/datetime/formatter.test.ts new file mode 100644 index 0000000..a97a6d5 --- /dev/null +++ b/packages/grafana-data/src/datetime/formatter.test.ts @@ -0,0 +1,76 @@ +import { dateTimeFormat } from './formatter'; + +describe('dateTimeFormat', () => { + describe('when no time zone have been set', () => { + const browserTime = dateTimeFormat(1587126975779, { timeZone: 'browser' }); + + it('should format with default formatting in browser/local time zone', () => { + expect(dateTimeFormat(1587126975779)).toBe(browserTime); + }); + }); + + describe('when invalid time zone have been set', () => { + const browserTime = dateTimeFormat(1587126975779, { timeZone: 'browser' }); + const options = { timeZone: 'asdf123' }; + + it('should format with default formatting in browser/local time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe(browserTime); + }); + }); + + describe('when UTC time zone have been set', () => { + const options = { timeZone: 'utc' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 12:36:15'); + }); + }); + + describe('when Europe/Stockholm time zone have been set', () => { + const options = { timeZone: 'Europe/Stockholm' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 14:36:15'); + }); + }); + + describe('when Australia/Perth time zone have been set', () => { + const options = { timeZone: 'Australia/Perth' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 20:36:15'); + }); + }); + + describe('when Asia/Yakutsk time zone have been set', () => { + const options = { timeZone: 'Asia/Yakutsk' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 21:36:15'); + }); + }); + + describe('when America/Panama time zone have been set', () => { + const options = { timeZone: 'America/Panama' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 07:36:15'); + }); + }); + + describe('when America/Los_Angeles time zone have been set', () => { + const options = { timeZone: 'America/Los_Angeles' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 05:36:15'); + }); + }); + + describe('when Africa/Djibouti time zone have been set', () => { + const options = { timeZone: 'Africa/Djibouti' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 15:36:15'); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/formatter.ts b/packages/grafana-data/src/datetime/formatter.ts new file mode 100644 index 0000000..108f0a7 --- /dev/null +++ b/packages/grafana-data/src/datetime/formatter.ts @@ -0,0 +1,107 @@ +/* eslint-disable id-blacklist, no-restricted-imports, @typescript-eslint/ban-types */ +import moment, { MomentInput, Moment } from 'moment-timezone'; +import { TimeZone } from '../types'; +import { DateTimeInput } from './moment_wrapper'; +import { systemDateFormats } from './formats'; +import { DateTimeOptions, getTimeZone } from './common'; + +/** + * The type describing the options that can be passed to the {@link dateTimeFormat} + * helper function to control how the date and time value passed to the function is + * formatted. + * + * @public + */ +export interface DateTimeOptionsWithFormat extends DateTimeOptions { + /** + * Set this value to `true` if you want to include milliseconds when formatting date and time + */ + defaultWithMS?: boolean; +} + +type DateTimeFormatter = (dateInUtc: DateTimeInput, options?: T) => string; + +/** + * Helper function to format date and time according to the specified options. If no options + * are supplied, then default values are used. For more details, see {@link DateTimeOptionsWithFormat}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormat: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format(getFormat(options)); + +/** + * Helper function to format date and time according to the standard ISO format e.g. 2013-02-04T22:44:30.652Z. + * If no options are supplied, then default values are used. For more details, see {@link DateTimeOptionsWithFormat}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormatISO: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format(); + +/** + * Helper function to return elapsed time since passed date. The returned value will be formatted + * in a human readable format e.g. 4 years ago. If no options are supplied, then default values are used. + * For more details, see {@link DateTimeOptions}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormatTimeAgo: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).fromNow(); + +/** + * Helper function to format date and time according to the Grafana default formatting, but it + * also appends the time zone abbreviation at the end e.g. 2020-05-20 13:37:00 CET. If no options + * are supplied, then default values are used. For more details please see {@link DateTimeOptions}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormatWithAbbrevation: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format(`${systemDateFormats.fullDate} z`); + +/** + * Helper function to return only the time zone abbreviation for a given date and time value. If no options + * are supplied, then default values are used. For more details please see {@link DateTimeOptions}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const timeZoneAbbrevation: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format('z'); + +const getFormat = (options?: T): string => { + if (options?.defaultWithMS) { + return options?.format ?? systemDateFormats.fullDateMS; + } + return options?.format ?? systemDateFormats.fullDate; +}; + +const toTz = (dateInUtc: DateTimeInput, timeZone: TimeZone): Moment => { + const date = dateInUtc as MomentInput; + const zone = moment.tz.zone(timeZone); + + if (zone && zone.name) { + return moment.utc(date).tz(zone.name); + } + + switch (timeZone) { + case 'utc': + return moment.utc(date); + default: + return moment.utc(date).local(); + } +}; diff --git a/packages/grafana-data/src/datetime/index.ts b/packages/grafana-data/src/datetime/index.ts new file mode 100644 index 0000000..b3e0cc2 --- /dev/null +++ b/packages/grafana-data/src/datetime/index.ts @@ -0,0 +1,10 @@ +// Names are too general to export globally +import * as dateMath from './datemath'; +import * as rangeUtil from './rangeutil'; +export * from './moment_wrapper'; +export * from './timezones'; +export * from './formats'; +export * from './formatter'; +export * from './parser'; +export { dateMath, rangeUtil }; +export { DateTimeOptions, setTimeZoneResolver, TimeZoneResolver, getTimeZone } from './common'; diff --git a/packages/grafana-data/src/datetime/moment_wrapper.ts b/packages/grafana-data/src/datetime/moment_wrapper.ts new file mode 100644 index 0000000..03910a3 --- /dev/null +++ b/packages/grafana-data/src/datetime/moment_wrapper.ts @@ -0,0 +1,122 @@ +import { TimeZone } from '../types/time'; +/* eslint-disable id-blacklist, no-restricted-imports, @typescript-eslint/ban-types */ +import moment, { Moment, MomentInput, DurationInputArg1, DurationInputArg2 } from 'moment'; +export interface DateTimeBuiltinFormat { + __momentBuiltinFormatBrand: any; +} +export const ISO_8601: DateTimeBuiltinFormat = moment.ISO_8601; +export type DateTimeInput = Date | string | number | Array | DateTime | null; // | undefined; +export type FormatInput = string | DateTimeBuiltinFormat | undefined; +export type DurationInput = string | number | DateTimeDuration; +export type DurationUnit = + | 'year' + | 'years' + | 'y' + | 'month' + | 'months' + | 'M' + | 'week' + | 'weeks' + | 'isoWeek' + | 'w' + | 'day' + | 'days' + | 'd' + | 'hour' + | 'hours' + | 'h' + | 'minute' + | 'minutes' + | 'm' + | 'second' + | 'seconds' + | 's' + | 'millisecond' + | 'milliseconds' + | 'ms' + | 'quarter' + | 'quarters' + | 'Q'; + +export interface DateTimeLocale { + firstDayOfWeek: () => number; +} + +export interface DateTimeDuration { + asHours: () => number; + hours: () => number; + minutes: () => number; + seconds: () => number; + asSeconds: () => number; +} + +export interface DateTime extends Object { + add: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime; + set: (unit: DurationUnit, amount: DateTimeInput) => void; + diff: (amount: DateTimeInput, unit?: DurationUnit, truncate?: boolean) => number; + endOf: (unitOfTime: DurationUnit) => DateTime; + format: (formatInput?: FormatInput) => string; + fromNow: (withoutSuffix?: boolean) => string; + from: (formaInput: DateTimeInput) => string; + isSame: (input?: DateTimeInput, granularity?: DurationUnit) => boolean; + isBefore: (input?: DateTimeInput) => boolean; + isValid: () => boolean; + local: () => DateTime; + locale: (locale: string) => DateTime; + startOf: (unitOfTime: DurationUnit) => DateTime; + subtract: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime; + toDate: () => Date; + toISOString: () => string; + isoWeekday: (day?: number | string) => number | string; + valueOf: () => number; + unix: () => number; + utc: () => DateTime; + utcOffset: () => number; + hour?: () => number; + minute?: () => number; +} + +export const setLocale = (language: string) => { + moment.locale(language); +}; + +export const getLocale = () => { + return moment.locale(); +}; + +export const getLocaleData = (): DateTimeLocale => { + return moment.localeData(); +}; + +export const isDateTime = (value: any): value is DateTime => { + return moment.isMoment(value); +}; + +export const toUtc = (input?: DateTimeInput, formatInput?: FormatInput): DateTime => { + return moment.utc(input as MomentInput, formatInput) as DateTime; +}; + +export const toDuration = (input?: DurationInput, unit?: DurationUnit): DateTimeDuration => { + // moment built-in types are a bit flaky, for example `isoWeek` is not in the type definition but it's present in the js source. + return moment.duration(input as DurationInputArg1, unit as DurationInputArg2) as DateTimeDuration; +}; + +export const dateTime = (input?: DateTimeInput, formatInput?: FormatInput): DateTime => { + return moment(input as MomentInput, formatInput) as DateTime; +}; + +export const dateTimeAsMoment = (input?: DateTimeInput) => { + return dateTime(input) as Moment; +}; + +export const dateTimeForTimeZone = ( + timezone?: TimeZone, + input?: DateTimeInput, + formatInput?: FormatInput +): DateTime => { + if (timezone === 'utc') { + return toUtc(input, formatInput); + } + + return dateTime(input, formatInput); +}; diff --git a/packages/grafana-data/src/datetime/parser.test.ts b/packages/grafana-data/src/datetime/parser.test.ts new file mode 100644 index 0000000..62209df --- /dev/null +++ b/packages/grafana-data/src/datetime/parser.test.ts @@ -0,0 +1,25 @@ +import { dateTimeParse } from './parser'; +import { systemDateFormats } from './formats'; + +describe('dateTimeParse', () => { + it('should be able to parse using default format', () => { + const date = dateTimeParse('2020-03-02 15:00:22', { timeZone: 'utc' }); + expect(date.format()).toEqual('2020-03-02T15:00:22Z'); + }); + + it('should be able to parse using default format', () => { + systemDateFormats.update({ + fullDate: 'MMMM D, YYYY, h:mm:ss a', + interval: {} as any, + useBrowserLocale: false, + }); + + const date = dateTimeParse('Aug 20, 2020 10:30:20 am', { timeZone: 'utc' }); + expect(date.format()).toEqual('2020-08-20T10:30:20Z'); + }); + + it('should be able to parse array formats used by calendar', () => { + const date = dateTimeParse([2020, 5, 10, 10, 30, 20], { timeZone: 'utc' }); + expect(date.format()).toEqual('2020-06-10T10:30:20Z'); + }); +}); diff --git a/packages/grafana-data/src/datetime/parser.ts b/packages/grafana-data/src/datetime/parser.ts new file mode 100644 index 0000000..8efabe5 --- /dev/null +++ b/packages/grafana-data/src/datetime/parser.ts @@ -0,0 +1,94 @@ +/* eslint-disable id-blacklist, no-restricted-imports, @typescript-eslint/ban-types */ +import moment, { MomentInput } from 'moment-timezone'; +import { DateTimeInput, DateTime, isDateTime } from './moment_wrapper'; +import { DateTimeOptions, getTimeZone } from './common'; +import { parse, isValid } from './datemath'; +import { lowerCase } from 'lodash'; +import { systemDateFormats } from './formats'; + +/** + * The type that describes options that can be passed when parsing a date and time value. + * @public + */ +export interface DateTimeOptionsWhenParsing extends DateTimeOptions { + /** + * If the input is a Grafana quick date, e.g. now-6h, then you can specify this to control + * whether the last part of the date and time value is included or excluded. + * + * Example: now-6h and the current time is 12:20:00 if roundUp is set to true + * the returned DateTime value will be 06:00:00. + */ + roundUp?: boolean; +} + +type DateTimeParser = (value: DateTimeInput, options?: T) => DateTime; + +/** + * Helper function to parse a number, text or Date to a DateTime value. If a timeZone is supplied the incoming value + * is parsed with that timeZone as a base. The only exception to this is if the passed value is in a UTC-based + * format. Then it will use UTC as the base. If no format is specified the current system format will be assumed. + * + * It can also parse the Grafana quick date and time format, e.g. now-6h will be parsed as Date.now() - 6 hours and + * returned as a valid DateTime value. + * + * If no options are supplied, then default values are used. For more details please see {@link DateTimeOptions}. + * + * @param value - should be a parsable date and time value + * @param options + * + * @public + */ +export const dateTimeParse: DateTimeParser = (value, options?): DateTime => { + if (isDateTime(value)) { + return value; + } + + if (typeof value === 'string') { + return parseString(value, options); + } + + return parseOthers(value, options); +}; + +const parseString = (value: string, options?: DateTimeOptionsWhenParsing): DateTime => { + if (value.indexOf('now') !== -1) { + if (!isValid(value)) { + return moment() as DateTime; + } + + const parsed = parse(value, options?.roundUp, options?.timeZone); + return parsed || (moment() as DateTime); + } + + const timeZone = getTimeZone(options); + const zone = moment.tz.zone(timeZone); + const format = options?.format ?? systemDateFormats.fullDate; + + if (zone && zone.name) { + return moment.tz(value, format, zone.name) as DateTime; + } + + switch (lowerCase(timeZone)) { + case 'utc': + return moment.utc(value, format) as DateTime; + default: + return moment(value, format) as DateTime; + } +}; + +const parseOthers = (value: DateTimeInput, options?: DateTimeOptionsWhenParsing): DateTime => { + const date = value as MomentInput; + const timeZone = getTimeZone(options); + const zone = moment.tz.zone(timeZone); + + if (zone && zone.name) { + return moment.tz(date, zone.name) as DateTime; + } + + switch (lowerCase(timeZone)) { + case 'utc': + return moment.utc(date) as DateTime; + default: + return moment(date) as DateTime; + } +}; diff --git a/packages/grafana-data/src/datetime/rangeutil.test.ts b/packages/grafana-data/src/datetime/rangeutil.test.ts new file mode 100644 index 0000000..18c0bbd --- /dev/null +++ b/packages/grafana-data/src/datetime/rangeutil.test.ts @@ -0,0 +1,99 @@ +import { TimeRange } from '../types/time'; +import { dateTime, rangeUtil } from './index'; +import { timeRangeToRelative } from './rangeutil'; + +describe('Range Utils', () => { + describe('relative time', () => { + it('should identify absolute vs relative', () => { + expect( + rangeUtil.isRelativeTimeRange({ + from: '1234', + to: '4567', + }) + ).toBe(false); + expect( + rangeUtil.isRelativeTimeRange({ + from: 'now-5', + to: 'now', + }) + ).toBe(true); + }); + }); + + describe('describe_interval', () => { + it('falls back to seconds if input is a number', () => { + expect(rangeUtil.describeInterval('123')).toEqual({ + sec: 1, + type: 's', + count: 123, + }); + }); + + it('parses a valid time unt string correctly', () => { + expect(rangeUtil.describeInterval('123h')).toEqual({ + sec: 3600, + type: 'h', + count: 123, + }); + }); + + it('fails if input is invalid', () => { + expect(() => rangeUtil.describeInterval('123xyz')).toThrow(); + expect(() => rangeUtil.describeInterval('xyz')).toThrow(); + }); + }); + + describe('relativeToTimeRange', () => { + it('should convert seconds to timeRange', () => { + const relativeTimeRange = { from: 600, to: 300 }; + const timeRange = rangeUtil.relativeToTimeRange(relativeTimeRange, dateTime('2021-04-20T15:55:00Z')); + + expect(timeRange.from.valueOf()).toEqual(dateTime('2021-04-20T15:45:00Z').valueOf()); + expect(timeRange.to.valueOf()).toEqual(dateTime('2021-04-20T15:50:00Z').valueOf()); + }); + + it('should convert from now', () => { + const relativeTimeRange = { from: 600, to: 0 }; + const timeRange = rangeUtil.relativeToTimeRange(relativeTimeRange, dateTime('2021-04-20T15:55:00Z')); + + expect(timeRange.from.valueOf()).toEqual(dateTime('2021-04-20T15:45:00Z').valueOf()); + expect(timeRange.to.valueOf()).toEqual(dateTime('2021-04-20T15:55:00Z').valueOf()); + }); + }); + + describe('timeRangeToRelative', () => { + it('should convert now-15m to relaitve time range', () => { + const now = dateTime('2021-04-20T15:55:00Z'); + const timeRange: TimeRange = { + from: dateTime(now).subtract(15, 'minutes'), + to: now, + raw: { + from: 'now-15m', + to: 'now', + }, + }; + + const relativeTimeRange = timeRangeToRelative(timeRange, now); + + expect(relativeTimeRange.from).toEqual(900); + expect(relativeTimeRange.to).toEqual(0); + }); + + it('should convert now-2w, now-1w to relative range', () => { + const now = dateTime('2021-04-20T15:55:00Z'); + const timeRange: TimeRange = { + from: dateTime(now).subtract(2, 'weeks'), + to: dateTime(now).subtract(1, 'week'), + raw: { + from: 'now-2w', + to: 'now-1w', + }, + }; + + const relativeTimeRange = timeRangeToRelative(timeRange, now); + + expect(relativeTimeRange.from).toEqual(1209600); + expect(relativeTimeRange.to).toEqual(604800); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts new file mode 100644 index 0000000..254ae80 --- /dev/null +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -0,0 +1,448 @@ +import { each, has } from 'lodash'; + +import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange, TimeOption } from '../types/time'; + +import * as dateMath from './datemath'; +import { isDateTime, DateTime, dateTime } from './moment_wrapper'; +import { timeZoneAbbrevation, dateTimeFormat, dateTimeFormatTimeAgo } from './formatter'; +import { dateTimeParse } from './parser'; + +const spans: { [key: string]: { display: string; section?: number } } = { + s: { display: 'second' }, + m: { display: 'minute' }, + h: { display: 'hour' }, + d: { display: 'day' }, + w: { display: 'week' }, + M: { display: 'month' }, + y: { display: 'year' }, +}; + +const rangeOptions: TimeOption[] = [ + { from: 'now/d', to: 'now/d', display: 'Today' }, + { from: 'now/d', to: 'now', display: 'Today so far' }, + { from: 'now/w', to: 'now/w', display: 'This week' }, + { from: 'now/w', to: 'now', display: 'This week so far' }, + { from: 'now/M', to: 'now/M', display: 'This month' }, + { from: 'now/M', to: 'now', display: 'This month so far' }, + { from: 'now/y', to: 'now/y', display: 'This year' }, + { from: 'now/y', to: 'now', display: 'This year so far' }, + + { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' }, + { + from: 'now-2d/d', + to: 'now-2d/d', + display: 'Day before yesterday', + }, + { + from: 'now-7d/d', + to: 'now-7d/d', + display: 'This day last week', + }, + { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' }, + { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' }, + { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' }, + + { from: 'now-5m', to: 'now', display: 'Last 5 minutes' }, + { from: 'now-15m', to: 'now', display: 'Last 15 minutes' }, + { from: 'now-30m', to: 'now', display: 'Last 30 minutes' }, + { from: 'now-1h', to: 'now', display: 'Last 1 hour' }, + { from: 'now-3h', to: 'now', display: 'Last 3 hours' }, + { from: 'now-6h', to: 'now', display: 'Last 6 hours' }, + { from: 'now-12h', to: 'now', display: 'Last 12 hours' }, + { from: 'now-24h', to: 'now', display: 'Last 24 hours' }, + { from: 'now-2d', to: 'now', display: 'Last 2 days' }, + { from: 'now-7d', to: 'now', display: 'Last 7 days' }, + { from: 'now-30d', to: 'now', display: 'Last 30 days' }, + { from: 'now-90d', to: 'now', display: 'Last 90 days' }, + { from: 'now-6M', to: 'now', display: 'Last 6 months' }, + { from: 'now-1y', to: 'now', display: 'Last 1 year' }, + { from: 'now-2y', to: 'now', display: 'Last 2 years' }, + { from: 'now-5y', to: 'now', display: 'Last 5 years' }, +]; + +const hiddenRangeOptions: TimeOption[] = [ + { from: 'now', to: 'now+1m', display: 'Next minute' }, + { from: 'now', to: 'now+5m', display: 'Next 5 minutes' }, + { from: 'now', to: 'now+15m', display: 'Next 15 minutes' }, + { from: 'now', to: 'now+30m', display: 'Next 30 minutes' }, + { from: 'now', to: 'now+1h', display: 'Next hour' }, + { from: 'now', to: 'now+3h', display: 'Next 3 hours' }, + { from: 'now', to: 'now+6h', display: 'Next 6 hours' }, + { from: 'now', to: 'now+12h', display: 'Next 12 hours' }, + { from: 'now', to: 'now+24h', display: 'Next 24 hours' }, + { from: 'now', to: 'now+2d', display: 'Next 2 days' }, + { from: 'now', to: 'now+7d', display: 'Next 7 days' }, + { from: 'now', to: 'now+30d', display: 'Next 30 days' }, + { from: 'now', to: 'now+90d', display: 'Next 90 days' }, + { from: 'now', to: 'now+6M', display: 'Next 6 months' }, + { from: 'now', to: 'now+1y', display: 'Next year' }, + { from: 'now', to: 'now+2y', display: 'Next 2 years' }, + { from: 'now', to: 'now+5y', display: 'Next 5 years' }, +]; + +const rangeIndex: any = {}; +each(rangeOptions, (frame: any) => { + rangeIndex[frame.from + ' to ' + frame.to] = frame; +}); +each(hiddenRangeOptions, (frame: any) => { + rangeIndex[frame.from + ' to ' + frame.to] = frame; +}); + +// handles expressions like +// 5m +// 5m to now/d +// now/d to now +// now/d +// if no to then to now is assumed +export function describeTextRange(expr: any) { + const isLast = expr.indexOf('+') !== 0; + if (expr.indexOf('now') === -1) { + expr = (isLast ? 'now-' : 'now') + expr; + } + + let opt = rangeIndex[expr + ' to now']; + if (opt) { + return opt; + } + + if (isLast) { + opt = { from: expr, to: 'now' }; + } else { + opt = { from: 'now', to: expr }; + } + + const parts = /^now([-+])(\d+)(\w)/.exec(expr); + if (parts) { + const unit = parts[3]; + const amount = parseInt(parts[2], 10); + const span = spans[unit]; + if (span) { + opt.display = isLast ? 'Last ' : 'Next '; + opt.display += amount + ' ' + span.display; + opt.section = span.section; + if (amount > 1) { + opt.display += 's'; + } + } + } else { + opt.display = opt.from + ' to ' + opt.to; + opt.invalid = true; + } + + return opt; +} + +/** + * Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}. + * + * @example + * ``` + * // Prints "2": + * console.log(add(1,1)); + * ``` + * @category TimeUtils + * @param range - a time range (usually specified by the TimePicker) + * @alpha + */ +export function describeTimeRange(range: RawTimeRange, timeZone?: TimeZone): string { + const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()]; + + if (option) { + return option.display; + } + + const options = { timeZone }; + + if (isDateTime(range.from) && isDateTime(range.to)) { + return dateTimeFormat(range.from, options) + ' to ' + dateTimeFormat(range.to, options); + } + + if (isDateTime(range.from)) { + const parsed = dateMath.parse(range.to, true, 'utc'); + return parsed ? dateTimeFormat(range.from, options) + ' to ' + dateTimeFormatTimeAgo(parsed, options) : ''; + } + + if (isDateTime(range.to)) { + const parsed = dateMath.parse(range.from, false, 'utc'); + return parsed ? dateTimeFormatTimeAgo(parsed, options) + ' to ' + dateTimeFormat(range.to, options) : ''; + } + + if (range.to.toString() === 'now') { + const res = describeTextRange(range.from); + return res.display; + } + + return range.from.toString() + ' to ' + range.to.toString(); +} + +export const isValidTimeSpan = (value: string) => { + if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) { + return true; + } + + const info = describeTextRange(value); + return info.invalid !== true; +}; + +export const describeTimeRangeAbbreviation = (range: TimeRange, timeZone?: TimeZone) => { + if (isDateTime(range.from)) { + return timeZoneAbbrevation(range.from, { timeZone }); + } + const parsed = dateMath.parse(range.from, true); + return parsed ? timeZoneAbbrevation(parsed, { timeZone }) : ''; +}; + +export const convertRawToRange = (raw: RawTimeRange, timeZone?: TimeZone): TimeRange => { + const from = dateTimeParse(raw.from, { roundUp: false, timeZone }); + const to = dateTimeParse(raw.to, { roundUp: true, timeZone }); + + if (dateMath.isMathString(raw.from) || dateMath.isMathString(raw.to)) { + return { from, to, raw }; + } + + return { from, to, raw: { from, to } }; +}; + +function isRelativeTime(v: DateTime | string) { + if (typeof v === 'string') { + return (v as string).indexOf('now') >= 0; + } + return false; +} + +export function isRelativeTimeRange(raw: RawTimeRange): boolean { + return isRelativeTime(raw.from) || isRelativeTime(raw.to); +} + +export function secondsToHms(seconds: number): string { + const numYears = Math.floor(seconds / 31536000); + if (numYears) { + return numYears + 'y'; + } + const numDays = Math.floor((seconds % 31536000) / 86400); + if (numDays) { + return numDays + 'd'; + } + const numHours = Math.floor(((seconds % 31536000) % 86400) / 3600); + if (numHours) { + return numHours + 'h'; + } + const numMinutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60); + if (numMinutes) { + return numMinutes + 'm'; + } + const numSeconds = Math.floor((((seconds % 31536000) % 86400) % 3600) % 60); + if (numSeconds) { + return numSeconds + 's'; + } + const numMilliseconds = Math.floor(seconds * 1000.0); + if (numMilliseconds) { + return numMilliseconds + 'ms'; + } + + return 'less than a millisecond'; //'just now' //or other string you like; +} + +// Format timeSpan (in sec) to string used in log's meta info +export function msRangeToTimeString(rangeMs: number): string { + const rangeSec = Number((rangeMs / 1000).toFixed()); + + const h = Math.floor(rangeSec / 60 / 60); + const m = Math.floor(rangeSec / 60) - h * 60; + const s = Number((rangeSec % 60).toFixed()); + let formattedH = h ? h + 'h' : ''; + let formattedM = m ? m + 'min' : ''; + let formattedS = s ? s + 'sec' : ''; + + formattedH && formattedM ? (formattedH = formattedH + ' ') : (formattedH = formattedH); + (formattedM || formattedH) && formattedS ? (formattedM = formattedM + ' ') : (formattedM = formattedM); + + return formattedH + formattedM + formattedS || 'less than 1sec'; +} + +export function calculateInterval(range: TimeRange, resolution: number, lowLimitInterval?: string): IntervalValues { + let lowLimitMs = 1; // 1 millisecond default low limit + if (lowLimitInterval) { + lowLimitMs = intervalToMs(lowLimitInterval); + } + + let intervalMs = roundInterval((range.to.valueOf() - range.from.valueOf()) / resolution); + if (lowLimitMs > intervalMs) { + intervalMs = lowLimitMs; + } + return { + intervalMs: intervalMs, + interval: secondsToHms(intervalMs / 1000), + }; +} + +const interval_regex = /(\d+(?:\.\d+)?)(ms|[Mwdhmsy])/; +// histogram & trends +const intervals_in_seconds = { + y: 31536000, + M: 2592000, + w: 604800, + d: 86400, + h: 3600, + m: 60, + s: 1, + ms: 0.001, +}; + +export function describeInterval(str: string) { + // Default to seconds if no unit is provided + if (Number(str)) { + return { + sec: intervals_in_seconds.s, + type: 's', + count: parseInt(str, 10), + }; + } + + const matches = str.match(interval_regex); + if (!matches || !has(intervals_in_seconds, matches[2])) { + throw new Error( + `Invalid interval string, has to be either unit-less or end with one of the following units: "${Object.keys( + intervals_in_seconds + ).join(', ')}"` + ); + } + return { + sec: (intervals_in_seconds as any)[matches[2]] as number, + type: matches[2], + count: parseInt(matches[1], 10), + }; +} + +export function intervalToSeconds(str: string): number { + const info = describeInterval(str); + return info.sec * info.count; +} + +export function intervalToMs(str: string): number { + const info = describeInterval(str); + return info.sec * 1000 * info.count; +} + +export function roundInterval(interval: number) { + switch (true) { + // 0.015s + case interval < 15: + return 10; // 0.01s + // 0.035s + case interval < 35: + return 20; // 0.02s + // 0.075s + case interval < 75: + return 50; // 0.05s + // 0.15s + case interval < 150: + return 100; // 0.1s + // 0.35s + case interval < 350: + return 200; // 0.2s + // 0.75s + case interval < 750: + return 500; // 0.5s + // 1.5s + case interval < 1500: + return 1000; // 1s + // 3.5s + case interval < 3500: + return 2000; // 2s + // 7.5s + case interval < 7500: + return 5000; // 5s + // 12.5s + case interval < 12500: + return 10000; // 10s + // 17.5s + case interval < 17500: + return 15000; // 15s + // 25s + case interval < 25000: + return 20000; // 20s + // 45s + case interval < 45000: + return 30000; // 30s + // 1.5m + case interval < 90000: + return 60000; // 1m + // 3.5m + case interval < 210000: + return 120000; // 2m + // 7.5m + case interval < 450000: + return 300000; // 5m + // 12.5m + case interval < 750000: + return 600000; // 10m + // 12.5m + case interval < 1050000: + return 900000; // 15m + // 25m + case interval < 1500000: + return 1200000; // 20m + // 45m + case interval < 2700000: + return 1800000; // 30m + // 1.5h + case interval < 5400000: + return 3600000; // 1h + // 2.5h + case interval < 9000000: + return 7200000; // 2h + // 4.5h + case interval < 16200000: + return 10800000; // 3h + // 9h + case interval < 32400000: + return 21600000; // 6h + // 1d + case interval < 86400000: + return 43200000; // 12h + // 1w + case interval < 604800000: + return 86400000; // 1d + // 3w + case interval < 1814400000: + return 604800000; // 1w + // 6w + case interval < 3628800000: + return 2592000000; // 30d + default: + return 31536000000; // 1y + } +} + +/** + * Converts a TimeRange to a RelativeTimeRange that can be used in + * e.g. alerting queries/rules. + * + * @internal + */ +export function timeRangeToRelative(timeRange: TimeRange, now: DateTime = dateTime()): RelativeTimeRange { + const from = now.unix() - timeRange.from.unix(); + const to = now.unix() - timeRange.to.unix(); + + return { + from, + to, + }; +} + +/** + * Converts a RelativeTimeRange to a TimeRange + * + * @internal + */ +export function relativeToTimeRange(relativeTimeRange: RelativeTimeRange, now: DateTime = dateTime()): TimeRange { + const from = dateTime(now).subtract(relativeTimeRange.from, 's'); + const to = relativeTimeRange.to === 0 ? dateTime(now) : dateTime(now).subtract(relativeTimeRange.to, 's'); + + return { + from, + to, + raw: { from, to }, + }; +} diff --git a/packages/grafana-data/src/datetime/timezones.test.ts b/packages/grafana-data/src/datetime/timezones.test.ts new file mode 100644 index 0000000..cd78b67 --- /dev/null +++ b/packages/grafana-data/src/datetime/timezones.test.ts @@ -0,0 +1,28 @@ +import { getTimeZoneInfo } from './timezones'; +import { setTimeZoneResolver } from './common'; + +describe('getTimeZoneInfo', () => { + // global timezone is set to Pacific/Easter, see jest-config.js file + + describe('IANA canonical name of the timezone', () => { + it('should resolve for default timezone', () => { + setTimeZoneResolver(() => 'browser'); + const result = getTimeZoneInfo('', Date.now()); + expect(result?.ianaName).toBe('Pacific/Easter'); + }); + + it('should resolve for browser timezone', () => { + const result = getTimeZoneInfo('browser', Date.now()); + expect(result?.ianaName).toBe('Pacific/Easter'); + }); + it('should resolve for utc timezone', () => { + const result = getTimeZoneInfo('utc', Date.now()); + expect(result?.ianaName).toBe('UTC'); + }); + + it('should resolve for given timezone', () => { + const result = getTimeZoneInfo('Europe/Warsaw', Date.now()); + expect(result?.ianaName).toBe('Europe/Warsaw'); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/timezones.ts b/packages/grafana-data/src/datetime/timezones.ts new file mode 100644 index 0000000..2d89ff3 --- /dev/null +++ b/packages/grafana-data/src/datetime/timezones.ts @@ -0,0 +1,436 @@ +import moment from 'moment-timezone'; +import { memoize } from 'lodash'; +import { TimeZone } from '../types'; +import { getTimeZone } from './common'; + +export enum InternalTimeZones { + default = '', + localBrowserTime = 'browser', + utc = 'utc', +} + +export const timeZoneFormatUserFriendly = (timeZone: TimeZone | undefined) => { + switch (getTimeZone({ timeZone })) { + case 'browser': + return 'Local browser time'; + case 'utc': + return 'UTC'; + default: + return timeZone; + } +}; + +export interface TimeZoneCountry { + code: string; + name: string; +} +export interface TimeZoneInfo { + name: string; + zone: string; + countries: TimeZoneCountry[]; + abbreviation: string; + offsetInMins: number; + ianaName: string; +} + +export interface GroupedTimeZones { + name: string; + zones: TimeZone[]; +} + +export const getTimeZoneInfo = (zone: string, timestamp: number): TimeZoneInfo | undefined => { + const internal = mapInternal(zone, timestamp); + + if (internal) { + return internal; + } + + return mapToInfo(zone, timestamp); +}; + +export const getTimeZones = memoize((includeInternal = false): TimeZone[] => { + const initial: TimeZone[] = []; + + if (includeInternal) { + initial.push.apply(initial, [InternalTimeZones.default, InternalTimeZones.localBrowserTime, InternalTimeZones.utc]); + } + + return moment.tz.names().reduce((zones: TimeZone[], zone: string) => { + const countriesForZone = countriesByTimeZone[zone]; + + if (!Array.isArray(countriesForZone) || countriesForZone.length === 0) { + return zones; + } + + zones.push(zone); + return zones; + }, initial); +}); + +export const getTimeZoneGroups = memoize((includeInternal = false): GroupedTimeZones[] => { + const timeZones = getTimeZones(includeInternal); + + const groups = timeZones.reduce((groups: Record, zone: TimeZone) => { + const delimiter = zone.indexOf('/'); + + if (delimiter === -1) { + const group = ''; + groups[group] = groups[group] ?? []; + groups[group].push(zone); + + return groups; + } + + const group = zone.substr(0, delimiter); + groups[group] = groups[group] ?? []; + groups[group].push(zone); + + return groups; + }, {}); + + return Object.keys(groups).map((name) => ({ + name, + zones: groups[name], + })); +}); + +const mapInternal = (zone: string, timestamp: number): TimeZoneInfo | undefined => { + switch (zone) { + case InternalTimeZones.utc: { + return { + name: 'Coordinated Universal Time', + ianaName: 'UTC', + zone, + countries: [], + abbreviation: 'UTC, GMT', + offsetInMins: 0, + }; + } + + case InternalTimeZones.default: { + const tz = getTimeZone(); + const isInternal = tz === 'browser' || tz === 'utc'; + const info = (isInternal ? mapInternal(tz, timestamp) : mapToInfo(tz, timestamp)) ?? {}; + + return { + countries: countriesByTimeZone[tz] ?? [], + abbreviation: '', + offsetInMins: 0, + ...info, + ianaName: (info as TimeZoneInfo).ianaName, + name: 'Default', + zone, + }; + } + + case InternalTimeZones.localBrowserTime: { + const tz = moment.tz.guess(true); + const info = mapToInfo(tz, timestamp) ?? {}; + + return { + countries: countriesByTimeZone[tz] ?? [], + abbreviation: 'Your local time', + offsetInMins: new Date().getTimezoneOffset(), + ...info, + name: 'Browser Time', + ianaName: (info as TimeZoneInfo).ianaName, + zone, + }; + } + + default: + return undefined; + } +}; + +const abbrevationWithoutOffset = (abbrevation: string): string => { + if (/^(\+|\-).+/.test(abbrevation)) { + return ''; + } + return abbrevation; +}; + +const mapToInfo = (timeZone: TimeZone, timestamp: number): TimeZoneInfo | undefined => { + const momentTz = moment.tz.zone(timeZone); + if (!momentTz) { + return undefined; + } + + return { + name: timeZone, + ianaName: momentTz.name, + zone: timeZone, + countries: countriesByTimeZone[timeZone] ?? [], + abbreviation: abbrevationWithoutOffset(momentTz.abbr(timestamp)), + offsetInMins: momentTz.utcOffset(timestamp), + }; +}; + +// Country names by ISO 3166-1-alpha-2 code +const countryByCode: Record = { + AF: 'Afghanistan', + AX: 'Aland Islands', + AL: 'Albania', + DZ: 'Algeria', + AS: 'American Samoa', + AD: 'Andorra', + AO: 'Angola', + AI: 'Anguilla', + AQ: 'Antarctica', + AG: 'Antigua And Barbuda', + AR: 'Argentina', + AM: 'Armenia', + AW: 'Aruba', + AU: 'Australia', + AT: 'Austria', + AZ: 'Azerbaijan', + BS: 'Bahamas', + BH: 'Bahrain', + BD: 'Bangladesh', + BB: 'Barbados', + BY: 'Belarus', + BE: 'Belgium', + BZ: 'Belize', + BJ: 'Benin', + BM: 'Bermuda', + BT: 'Bhutan', + BO: 'Bolivia', + BA: 'Bosnia And Herzegovina', + BW: 'Botswana', + BV: 'Bouvet Island', + BR: 'Brazil', + IO: 'British Indian Ocean Territory', + BN: 'Brunei Darussalam', + BG: 'Bulgaria', + BF: 'Burkina Faso', + BI: 'Burundi', + KH: 'Cambodia', + CM: 'Cameroon', + CA: 'Canada', + CV: 'Cape Verde', + KY: 'Cayman Islands', + CF: 'Central African Republic', + TD: 'Chad', + CL: 'Chile', + CN: 'China', + CX: 'Christmas Island', + CC: 'Cocos (Keeling) Islands', + CO: 'Colombia', + KM: 'Comoros', + CG: 'Congo', + CD: 'Congo, Democratic Republic', + CK: 'Cook Islands', + CR: 'Costa Rica', + CI: "Cote D'Ivoire", + HR: 'Croatia', + CU: 'Cuba', + CY: 'Cyprus', + CZ: 'Czech Republic', + DK: 'Denmark', + DJ: 'Djibouti', + DM: 'Dominica', + DO: 'Dominican Republic', + EC: 'Ecuador', + EG: 'Egypt', + SV: 'El Salvador', + GQ: 'Equatorial Guinea', + ER: 'Eritrea', + EE: 'Estonia', + ET: 'Ethiopia', + FK: 'Falkland Islands (Malvinas)', + FO: 'Faroe Islands', + FJ: 'Fiji', + FI: 'Finland', + FR: 'France', + GF: 'French Guiana', + PF: 'French Polynesia', + TF: 'French Southern Territories', + GA: 'Gabon', + GM: 'Gambia', + GE: 'Georgia', + DE: 'Germany', + GH: 'Ghana', + GI: 'Gibraltar', + GR: 'Greece', + GL: 'Greenland', + GD: 'Grenada', + GP: 'Guadeloupe', + GU: 'Guam', + GT: 'Guatemala', + GG: 'Guernsey', + GN: 'Guinea', + GW: 'Guinea-Bissau', + GY: 'Guyana', + HT: 'Haiti', + HM: 'Heard Island & Mcdonald Islands', + VA: 'Holy See (Vatican City State)', + HN: 'Honduras', + HK: 'Hong Kong', + HU: 'Hungary', + IS: 'Iceland', + IN: 'India', + ID: 'Indonesia', + IR: 'Iran (Islamic Republic Of)', + IQ: 'Iraq', + IE: 'Ireland', + IM: 'Isle Of Man', + IL: 'Israel', + IT: 'Italy', + JM: 'Jamaica', + JP: 'Japan', + JE: 'Jersey', + JO: 'Jordan', + KZ: 'Kazakhstan', + KE: 'Kenya', + KI: 'Kiribati', + KR: 'Korea', + KW: 'Kuwait', + KG: 'Kyrgyzstan', + LA: "Lao People's Democratic Republic", + LV: 'Latvia', + LB: 'Lebanon', + LS: 'Lesotho', + LR: 'Liberia', + LY: 'Libyan Arab Jamahiriya', + LI: 'Liechtenstein', + LT: 'Lithuania', + LU: 'Luxembourg', + MO: 'Macao', + MK: 'Macedonia', + MG: 'Madagascar', + MW: 'Malawi', + MY: 'Malaysia', + MV: 'Maldives', + ML: 'Mali', + MT: 'Malta', + MH: 'Marshall Islands', + MQ: 'Martinique', + MR: 'Mauritania', + MU: 'Mauritius', + YT: 'Mayotte', + MX: 'Mexico', + FM: 'Micronesia (Federated States Of)', + MD: 'Moldova', + MC: 'Monaco', + MN: 'Mongolia', + ME: 'Montenegro', + MS: 'Montserrat', + MA: 'Morocco', + MZ: 'Mozambique', + MM: 'Myanmar', + NA: 'Namibia', + NR: 'Nauru', + NP: 'Nepal', + NL: 'Netherlands', + AN: 'Netherlands Antilles', + NC: 'New Caledonia', + NZ: 'New Zealand', + NI: 'Nicaragua', + NE: 'Niger', + NG: 'Nigeria', + NU: 'Niue', + NF: 'Norfolk Island', + MP: 'Northern Mariana Islands', + NO: 'Norway', + OM: 'Oman', + PK: 'Pakistan', + PW: 'Palau', + PS: 'Palestinian Territory (Occupied)', + PA: 'Panama', + PG: 'Papua New Guinea', + PY: 'Paraguay', + PE: 'Peru', + PH: 'Philippines', + PN: 'Pitcairn', + PL: 'Poland', + PT: 'Portugal', + PR: 'Puerto Rico', + QA: 'Qatar', + RE: 'Reunion', + RO: 'Romania', + RU: 'Russian Federation', + RW: 'Rwanda', + BL: 'Saint Barthelemy', + SH: 'Saint Helena', + KN: 'Saint Kitts And Nevis', + LC: 'Saint Lucia', + MF: 'Saint Martin', + PM: 'Saint Pierre And Miquelon', + VC: 'Saint Vincent And Grenadines', + WS: 'Samoa', + SM: 'San Marino', + ST: 'Sao Tome And Principe', + SA: 'Saudi Arabia', + SN: 'Senegal', + RS: 'Serbia', + SC: 'Seychelles', + SL: 'Sierra Leone', + SG: 'Singapore', + SK: 'Slovakia', + SI: 'Slovenia', + SB: 'Solomon Islands', + SO: 'Somalia', + ZA: 'South Africa', + GS: 'South Georgia And Sandwich Isl.', + ES: 'Spain', + LK: 'Sri Lanka', + SD: 'Sudan', + SR: 'Suriname', + SJ: 'Svalbard And Jan Mayen', + SZ: 'Swaziland', + SE: 'Sweden', + CH: 'Switzerland', + SY: 'Syrian Arab Republic', + TW: 'Taiwan', + TJ: 'Tajikistan', + TZ: 'Tanzania', + TH: 'Thailand', + TL: 'Timor-Leste', + TG: 'Togo', + TK: 'Tokelau', + TO: 'Tonga', + TT: 'Trinidad And Tobago', + TN: 'Tunisia', + TR: 'Turkey', + TM: 'Turkmenistan', + TC: 'Turks And Caicos Islands', + TV: 'Tuvalu', + UG: 'Uganda', + UA: 'Ukraine', + AE: 'United Arab Emirates', + GB: 'United Kingdom', + US: 'United States', + UM: 'United States Outlying Islands', + UY: 'Uruguay', + UZ: 'Uzbekistan', + VU: 'Vanuatu', + VE: 'Venezuela', + VN: 'Viet Nam', + VG: 'Virgin Islands, British', + VI: 'Virgin Islands, U.S.', + WF: 'Wallis And Futuna', + EH: 'Western Sahara', + YE: 'Yemen', + ZM: 'Zambia', + ZW: 'Zimbabwe', +}; + +const countriesByTimeZone = ((): Record => { + return moment.tz.countries().reduce((all: Record, code) => { + const timeZones = moment.tz.zonesForCountry(code); + return timeZones.reduce((all: Record, timeZone) => { + if (!all[timeZone]) { + all[timeZone] = []; + } + + const name = countryByCode[code]; + + if (!name) { + return all; + } + + all[timeZone].push({ code, name }); + return all; + }, all); + }, {}); +})(); diff --git a/packages/grafana-data/src/events/EventBus.test.ts b/packages/grafana-data/src/events/EventBus.test.ts new file mode 100644 index 0000000..e0b0ca3 --- /dev/null +++ b/packages/grafana-data/src/events/EventBus.test.ts @@ -0,0 +1,195 @@ +import { EventBusSrv } from './EventBus'; +import { BusEvent, BusEventWithPayload } from './types'; +import { eventFactory } from './eventFactory'; +import { DataHoverEvent } from './common'; + +interface LoginEventPayload { + logins: number; +} + +interface HelloEventPayload { + hellos: number; +} + +class LoginEvent extends BusEventWithPayload { + static type = 'login-event'; +} + +class HelloEvent extends BusEventWithPayload { + static type = 'hello-event'; +} + +type LegacyEventPayload = [string, string]; + +export const legacyEvent = eventFactory('legacy-event'); + +class AlertSuccessEvent extends BusEventWithPayload { + static type = 'legacy-event'; +} + +describe('EventBus', () => { + it('Can create events', () => { + expect(new LoginEvent({ logins: 1 }).type).toBe('login-event'); + }); + + it('Can subscribe specific event', () => { + const bus = new EventBusSrv(); + const events: LoginEvent[] = []; + + bus.subscribe(LoginEvent, (event) => { + events.push(event); + }); + + bus.publish(new LoginEvent({ logins: 10 })); + bus.publish(new HelloEvent({ hellos: 10 })); + + expect(events[0].payload.logins).toBe(10); + expect(events.length).toBe(1); + }); + + describe('EventBusWithSource', () => { + it('can add sources to the source path', () => { + const bus = new EventBusSrv(); + const busWithSource = bus.newScopedBus('foo'); + expect((busWithSource as any).path).toEqual(['foo']); + }); + + it('adds the source to the event payload', () => { + const bus = new EventBusSrv(); + let events: BusEvent[] = []; + + bus.subscribe(DataHoverEvent, (event) => events.push(event)); + + const busWithSource = bus.newScopedBus('foo'); + busWithSource.publish({ type: DataHoverEvent.type }); + + expect(events.length).toEqual(1); + expect(events[0].origin).toEqual(busWithSource); + }); + }); + + describe('Legacy emitter behavior', () => { + it('Supports legacy events', () => { + const bus = new EventBusSrv(); + const events: any = []; + const handler = (event: LegacyEventPayload) => { + events.push(event); + }; + + bus.on(legacyEvent, handler); + bus.emit(legacyEvent, ['hello', 'hello2']); + + bus.off(legacyEvent, handler); + bus.emit(legacyEvent, ['hello', 'hello2']); + + expect(events.length).toEqual(1); + expect(events[0]).toEqual(['hello', 'hello2']); + }); + + it('Interoperability with legacy events', () => { + const bus = new EventBusSrv(); + const legacyEvents: any = []; + const newEvents: any = []; + + bus.on(legacyEvent, (event) => { + legacyEvents.push(event); + }); + + bus.subscribe(AlertSuccessEvent, (event) => { + newEvents.push(event); + }); + + bus.emit(legacyEvent, ['legacy', 'params']); + bus.publish(new AlertSuccessEvent(['new', 'event'])); + + expect(legacyEvents).toEqual([ + ['legacy', 'params'], + ['new', 'event'], + ]); + + expect(newEvents).toEqual([ + { + type: 'legacy-event', + payload: ['legacy', 'params'], + }, + { + type: 'legacy-event', + payload: ['new', 'event'], + }, + ]); + }); + + it('should notfiy subscribers', () => { + const bus = new EventBusSrv(); + let sub1Called = false; + let sub2Called = false; + + bus.on(legacyEvent, () => { + sub1Called = true; + }); + bus.on(legacyEvent, () => { + sub2Called = true; + }); + + bus.emit(legacyEvent, null); + + expect(sub1Called).toBe(true); + expect(sub2Called).toBe(true); + }); + + it('when subscribing twice', () => { + const bus = new EventBusSrv(); + let sub1Called = 0; + + function handler() { + sub1Called += 1; + } + + bus.on(legacyEvent, handler); + bus.on(legacyEvent, handler); + + bus.emit(legacyEvent, null); + + expect(sub1Called).toBe(2); + }); + + it('should handle errors', () => { + const bus = new EventBusSrv(); + let sub1Called = 0; + let sub2Called = 0; + + bus.on(legacyEvent, () => { + sub1Called++; + throw { message: 'hello' }; + }); + + bus.on(legacyEvent, () => { + sub2Called++; + }); + + try { + bus.emit(legacyEvent, null); + } catch (_) {} + try { + bus.emit(legacyEvent, null); + } catch (_) {} + + expect(sub1Called).toBe(2); + expect(sub2Called).toBe(0); + }); + + it('removeAllListeners should unsubscribe to all', () => { + const bus = new EventBusSrv(); + const events: LoginEvent[] = []; + + bus.subscribe(LoginEvent, (event) => { + events.push(event); + }); + + bus.removeAllListeners(); + bus.publish(new LoginEvent({ logins: 10 })); + + expect(events.length).toBe(0); + }); + }); +}); diff --git a/packages/grafana-data/src/events/EventBus.ts b/packages/grafana-data/src/events/EventBus.ts new file mode 100644 index 0000000..2dd3e7a --- /dev/null +++ b/packages/grafana-data/src/events/EventBus.ts @@ -0,0 +1,145 @@ +import EventEmitter from 'eventemitter3'; +import { Unsubscribable, Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { + EventBus, + LegacyEmitter, + BusEventHandler, + BusEventType, + LegacyEventHandler, + BusEvent, + AppEvent, + EventFilterOptions, +} from './types'; + +/** + * @alpha + */ +export class EventBusSrv implements EventBus, LegacyEmitter { + private emitter: EventEmitter; + + constructor() { + this.emitter = new EventEmitter(); + } + + publish(event: T): void { + this.emitter.emit(event.type, event); + } + + subscribe(typeFilter: BusEventType, handler: BusEventHandler): Unsubscribable { + return this.getStream(typeFilter).subscribe({ next: handler }); + } + + getStream(eventType: BusEventType): Observable { + return new Observable((observer) => { + const handler = (event: T) => { + observer.next(event); + }; + + this.emitter.on(eventType.type, handler); + + return () => { + this.emitter.off(eventType.type, handler); + }; + }); + } + + newScopedBus(key: string, filter?: EventFilterOptions): EventBus { + return new ScopedEventBus([key], this, filter); + } + + /** + * Legacy functions + */ + emit(event: AppEvent | string, payload?: T | any): void { + // console.log(`Deprecated emitter function used (emit), use $emit`); + + if (typeof event === 'string') { + this.emitter.emit(event, { type: event, payload }); + } else { + this.emitter.emit(event.name, { type: event.name, payload }); + } + } + + on(event: AppEvent | string, handler: LegacyEventHandler, scope?: any) { + // console.log(`Deprecated emitter function used (on), use $on`); + + // need this wrapper to make old events compatible with old handlers + handler.wrapper = (emittedEvent: BusEvent) => { + handler(emittedEvent.payload); + }; + + if (typeof event === 'string') { + this.emitter.on(event, handler.wrapper); + } else { + this.emitter.on(event.name, handler.wrapper); + } + + if (scope) { + const unbind = scope.$on('$destroy', () => { + this.off(event, handler); + unbind(); + }); + } + } + + off(event: AppEvent | string, handler: LegacyEventHandler) { + if (typeof event === 'string') { + this.emitter.off(event, handler.wrapper); + return; + } + + this.emitter.off(event.name, handler.wrapper); + } + + removeAllListeners() { + this.emitter.removeAllListeners(); + } +} + +/** + * Wraps EventBus and adds a source to help with identifying if a subscriber should react to the event or not. + */ +class ScopedEventBus implements EventBus { + // will be mutated by panel runners + filterConfig: EventFilterOptions; + + // The path is not yet exposed, but can be used to indicate nested groups and support faster filtering + constructor(public path: string[], private eventBus: EventBus, filter?: EventFilterOptions) { + this.filterConfig = filter ?? { onlyLocal: false }; + } + + publish(event: T): void { + if (!event.origin) { + (event as any).origin = this; + } + this.eventBus.publish(event); + } + + filter = (event: BusEvent) => { + if (this.filterConfig.onlyLocal) { + return event.origin === this; + } + return true; + }; + + getStream(eventType: BusEventType): Observable { + return this.eventBus.getStream(eventType).pipe(filter(this.filter)) as Observable; + } + + // syntax sugar + subscribe(typeFilter: BusEventType, handler: BusEventHandler): Unsubscribable { + return this.getStream(typeFilter).subscribe({ next: handler }); + } + + removeAllListeners(): void { + this.eventBus.removeAllListeners(); + } + + /** + * Creates a nested event bus structure + */ + newScopedBus(key: string, filter: EventFilterOptions): EventBus { + return new ScopedEventBus([...this.path, key], this, filter); + } +} diff --git a/packages/grafana-data/src/events/common.ts b/packages/grafana-data/src/events/common.ts new file mode 100644 index 0000000..1dc5ba6 --- /dev/null +++ b/packages/grafana-data/src/events/common.ts @@ -0,0 +1,36 @@ +import { DataFrame } from '../types'; +import { BusEventWithPayload } from './types'; + +/** + * When hovering over an element this will identify + * + * For performance reasons, this object will usually be mutated between updates. This + * will avoid creating new objects for events that fire frequently (ie each mouse pixel) + * + * @alpha + */ +export interface DataHoverPayload { + data?: DataFrame; // source data + rowIndex?: number; // the hover row + columnIndex?: number; // the hover column + dataId?: string; // identifying string to correlate data between publishers and subscribers + + // When dragging, this will capture the point when the mouse was down + point: Record; // { time: 5678, lengthft: 456 } // each axis|scale gets a value + down?: Record; +} + +/** @alpha */ +export class DataHoverEvent extends BusEventWithPayload { + static type = 'data-hover'; +} + +/** @alpha */ +export class DataHoverClearEvent extends BusEventWithPayload { + static type = 'data-hover-clear'; +} + +/** @alpha */ +export class DataSelectEvent extends BusEventWithPayload { + static type = 'data-select'; +} diff --git a/packages/grafana-data/src/events/eventFactory.ts b/packages/grafana-data/src/events/eventFactory.ts new file mode 100644 index 0000000..2dd1b92 --- /dev/null +++ b/packages/grafana-data/src/events/eventFactory.ts @@ -0,0 +1,12 @@ +import { AppEvent } from './types'; + +const typeList: Set = new Set(); + +export function eventFactory(name: string): AppEvent { + if (typeList.has(name)) { + throw new Error(`There is already an event defined with type '${name}'`); + } + + typeList.add(name); + return { name }; +} diff --git a/packages/grafana-data/src/events/index.ts b/packages/grafana-data/src/events/index.ts new file mode 100644 index 0000000..3587565 --- /dev/null +++ b/packages/grafana-data/src/events/index.ts @@ -0,0 +1,4 @@ +export * from './eventFactory'; +export * from './types'; +export * from './EventBus'; +export * from './common'; diff --git a/packages/grafana-data/src/events/types.ts b/packages/grafana-data/src/events/types.ts new file mode 100644 index 0000000..6f54cd6 --- /dev/null +++ b/packages/grafana-data/src/events/types.ts @@ -0,0 +1,134 @@ +import { Unsubscribable, Observable } from 'rxjs'; + +/** + * @alpha + * internal interface + */ +export interface BusEvent { + readonly type: string; + readonly payload?: any; + readonly origin?: EventBus; +} + +/** + * @alpha + * Base event type + */ +export abstract class BusEventBase implements BusEvent { + readonly type: string; + readonly payload?: any; + readonly origin?: EventBus; + + constructor() { + //@ts-ignore + this.type = this.__proto__.constructor.type; + } +} + +/** + * @alpha + * Base event type with payload + */ +export abstract class BusEventWithPayload extends BusEventBase { + readonly payload: T; + + constructor(payload: T) { + super(); + this.payload = payload; + } +} + +/* + * Interface for an event type constructor + */ +export interface BusEventType { + type: string; + new (...args: any[]): T; +} + +/** + * @alpha + * Event callback/handler type + */ +export interface BusEventHandler { + (event: T): void; +} + +/** + * @alpha + * Main minimal interface + */ +export interface EventFilterOptions { + onlyLocal: boolean; +} + +/** + * @alpha + * Main minimal interface + */ +export interface EventBus { + /** + * Publish single vent + */ + publish(event: T): void; + + /** + * Get observable of events + */ + getStream(eventType: BusEventType): Observable; + + /** + * Subscribe to an event stream + * + * This function is a wrapper around the `getStream(...)` function + */ + subscribe(eventType: BusEventType, handler: BusEventHandler): Unsubscribable; + + /** + * Remove all event subscriptions + */ + removeAllListeners(): void; + + /** + * Returns a new bus scoped that knows where it exists in a heiarchy + * + * @internal -- This is included for internal use only should not be used directly + */ + newScopedBus(key: string, filter: EventFilterOptions): EventBus; +} + +/** + * @public + * @deprecated event type + */ +export interface AppEvent { + readonly name: string; + payload?: T; +} + +/** @public */ +export interface LegacyEmitter { + /** + * @deprecated use $emit + */ + emit(event: AppEvent | string, payload?: T): void; + + /** + * @deprecated use $on + */ + on(event: AppEvent | string, handler: LegacyEventHandler, scope?: any): void; + + /** + * @deprecated use $on + */ + off(event: AppEvent | string, handler: (payload?: T | any) => void): void; +} + +/** @public */ +export interface LegacyEventHandler { + (payload: T): void; + wrapper?: (event: BusEvent) => void; +} + +/** @alpha */ +export interface EventBusExtended extends EventBus, LegacyEmitter {} diff --git a/packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx b/packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx new file mode 100644 index 0000000..9166a6b --- /dev/null +++ b/packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx @@ -0,0 +1,4 @@ +import { Registry } from '../utils/Registry'; +import { FieldConfigPropertyItem } from '../types/fieldOverrides'; + +export class FieldConfigOptionsRegistry extends Registry {} diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts new file mode 100644 index 0000000..f527ae0 --- /dev/null +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -0,0 +1,380 @@ +import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; +import { DisplayProcessor, DisplayValue } from '../types/displayValue'; +import { MappingType, ValueMapping } from '../types/valueMapping'; +import { FieldConfig, FieldType, ThresholdsMode } from '../types'; +import { systemDateFormats } from '../datetime'; +import { createTheme } from '../themes'; + +function getDisplayProcessorFromConfig(config: FieldConfig) { + return getDisplayProcessor({ + field: { + config, + type: FieldType.number, + }, + theme: createTheme(), + }); +} + +function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) { + processors.forEach((processor) => { + const value = processor(input); + for (const key of Object.keys(match)) { + expect((value as any)[key]).toEqual((match as any)[key]); + } + }); +} + +describe('Process simple display values', () => { + // Don't test float values here since the decimal formatting changes + const processors = [ + // Without options, this shortcuts to a much easier implementation + getDisplayProcessor({ field: { config: {} }, theme: createTheme() }), + + // Add a simple option that is not used (uses a different base class) + getDisplayProcessorFromConfig({ min: 0, max: 100 }), + + // Add a simple option that is not used (uses a different base class) + getDisplayProcessorFromConfig({ unit: 'locale' }), + ]; + + it('support null', () => { + assertSame(null, processors, { text: '', numeric: NaN }); + }); + + it('support undefined', () => { + assertSame(undefined, processors, { text: '', numeric: NaN }); + }); + + it('support NaN', () => { + assertSame(NaN, processors, { text: 'NaN', numeric: NaN }); + }); + + it('Integer', () => { + assertSame(3, processors, { text: '3', numeric: 3 }); + }); + + it('Text to number', () => { + assertSame('3', processors, { text: '3', numeric: 3 }); + }); + + it('Empty string is NaN', () => { + assertSame('', processors, { text: '', numeric: NaN }); + }); + + it('Simple String', () => { + assertSame('hello', processors, { text: 'hello', numeric: NaN }); + }); + + it('empty array', () => { + assertSame([], processors, { text: '', numeric: NaN }); + }); + + it('array of text', () => { + assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN }); + }); + + it('array of numbers', () => { + assertSame([1, 2, 3], processors, { text: '1,2,3', numeric: NaN }); + }); + + it('empty object', () => { + assertSame({}, processors, { text: '[object Object]', numeric: NaN }); + }); + + it('boolean true', () => { + assertSame(true, processors, { text: 'true', numeric: 1 }); + }); + + it('boolean false', () => { + assertSame(false, processors, { text: 'false', numeric: 0 }); + }); +}); + +describe('Process null values', () => { + const processors = [ + getDisplayProcessorFromConfig({ + min: 0, + max: 100, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: '#000' }, + { value: 0, color: '#100' }, + { value: 100, color: '#200' }, + ], + }, + }), + ]; + + it('Null should get -Infinity (base) color', () => { + assertSame(null, processors, { text: '', numeric: NaN, color: '#000' }); + }); +}); + +describe('Format value', () => { + it('should return if value isNaN', () => { + const valueMappings: ValueMapping[] = []; + const value = 'N/A'; + const instance = getDisplayProcessorFromConfig({ mappings: valueMappings }); + + const result = instance(value); + + expect(result.text).toEqual('N/A'); + }); + + it('should return formatted value if there are no value mappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '6'; + + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + + const result = instance(value); + + expect(result.text).toEqual('6.0'); + }); + + it('should return formatted value if there are no matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { type: MappingType.ValueToText, options: { '11': { text: 'elva' } } }, + { type: MappingType.RangeToText, options: { from: 1, to: 9, result: { text: '1-9' } } }, + ]; + + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + const result = instance('10'); + + expect(result.text).toEqual('10.0'); + }); + + it('should return mapped value if there are matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { type: MappingType.ValueToText, options: { '11': { text: 'elva' } } }, + { type: MappingType.RangeToText, options: { from: 1, to: 9, result: { text: '1-9' } } }, + ]; + + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + const result = instance('11'); + + expect(result.text).toEqual('elva'); + }); + + it('should return value with color if mapping has color', () => { + const valueMappings: ValueMapping[] = [{ type: MappingType.ValueToText, options: { Low: { color: 'red' } } }]; + + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + const result = instance('Low'); + + expect(result.text).toEqual('Low'); + expect(result.color).toEqual('#F2495C'); + }); + + it('should return mapped value and leave numeric value in tact if value mapping maps to empty string', () => { + const valueMappings: ValueMapping[] = [{ type: MappingType.ValueToText, options: { '1': { text: '' } } }]; + const value = '1'; + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + + expect(instance(value).text).toEqual(''); + expect(instance(value).numeric).toEqual(1); + }); + + it('should not map 1kW to the value for 1W', () => { + const valueMappings: ValueMapping[] = [{ type: MappingType.ValueToText, options: { '1': { text: 'mapped' } } }]; + const value = '1000'; + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings, unit: 'watt' }); + + const result = instance(value); + + expect(result.text).toEqual('1.0'); + }); + + it('With null value and thresholds should use base color', () => { + const instance = getDisplayProcessorFromConfig({ + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [{ value: -Infinity, color: '#AAA' }], + }, + }); + const disp = instance(null); + expect(disp.text).toEqual(''); + expect(disp.color).toEqual('#AAA'); + }); + + // + // Below is current behavior but it's clearly not working great + // + + it('with value 1000 and unit short', () => { + const value = 1000; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1'); + expect(disp.suffix).toEqual(' K'); + }); + + it('with value 1200 and unit short', () => { + const value = 1200; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1.20'); + expect(disp.suffix).toEqual(' K'); + }); + + it('with value 1250 and unit short', () => { + const value = 1250; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1.25'); + expect(disp.suffix).toEqual(' K'); + }); + + it('with value 10000000 and unit short', () => { + const value = 1000000; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1'); + expect(disp.suffix).toEqual(' Mil'); + }); + + it('with value 15000000 and unit short', () => { + const value = 1500000; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1.50'); + expect(disp.suffix).toEqual(' Mil'); + }); + + it('with value 128000000 and unit bytes', () => { + const value = 1280000125; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'bytes' }); + const disp = instance(value); + expect(disp.text).toEqual('1.19'); + expect(disp.suffix).toEqual(' GiB'); + }); +}); + +describe('Date display options', () => { + it('should format UTC dates', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: { + unit: 'xyz', // ignore non-date formats + }, + }, + theme: createTheme(), + }); + expect(processor(0).text).toEqual('1970-01-01 00:00:00'); + }); + + it('should pick configured time format', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: { + unit: 'dateTimeAsUS', // ignore non-date formats + }, + }, + theme: createTheme(), + }); + expect(processor(0).text).toEqual('01/01/1970 12:00:00 am'); + }); + + it('respect the configured date format', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: { + unit: 'time:YYYY', // ignore non-date formats + }, + }, + theme: createTheme(), + }); + expect(processor(0).text).toEqual('1970'); + }); + + it('Should use system date format by default', () => { + const currentFormat = systemDateFormats.fullDate; + systemDateFormats.fullDate = 'YYYY-MM'; + + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: {}, + }, + theme: createTheme(), + }); + + expect(processor(0).text).toEqual('1970-01'); + + systemDateFormats.fullDate = currentFormat; + }); + + it('should handle ISO string dates', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: {}, + }, + theme: createTheme(), + }); + + expect(processor('2020-08-01T08:48:43.783337Z').text).toEqual('2020-08-01 08:48:43'); + }); + + describe('number formatting for string values', () => { + it('should preserve string unchanged if unit is strings', () => { + const processor = getDisplayProcessor({ + field: { + type: FieldType.string, + config: { unit: 'string' }, + }, + theme: createTheme(), + }); + expect(processor('22.1122334455').text).toEqual('22.1122334455'); + }); + + it('should format string as number if no unit', () => { + const processor = getDisplayProcessor({ + field: { + type: FieldType.string, + config: { decimals: 2 }, + }, + theme: createTheme(), + }); + expect(processor('22.1122334455').text).toEqual('22.11'); + + // Support empty/missing strings + expect(processor(undefined).text).toEqual(''); + expect(processor(null).text).toEqual(''); + expect(processor('').text).toEqual(''); + }); + }); +}); + +describe('getRawDisplayProcessor', () => { + const processor = getRawDisplayProcessor(); + const date = new Date('2020-01-01T00:00:00.000Z'); + const timestamp = date.valueOf(); + + it.each` + value | expected + ${0} | ${'0'} + ${13.37} | ${'13.37'} + ${true} | ${'true'} + ${false} | ${'false'} + ${date} | ${`${date}`} + ${timestamp} | ${'1577836800000'} + ${'a string'} | ${'a string'} + ${null} | ${'null'} + ${undefined} | ${'undefined'} + ${{ value: 0, label: 'a label' }} | ${'[object Object]'} + `('when called with value:{$value}', ({ value, expected }) => { + const result = processor(value); + + expect(result).toEqual({ text: expected, numeric: null }); + }); +}); diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts new file mode 100644 index 0000000..6b10807 --- /dev/null +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -0,0 +1,159 @@ +// Libraries +import { toString, toNumber as _toNumber, isEmpty, isBoolean } from 'lodash'; + +// Types +import { Field, FieldType } from '../types/dataFrame'; +import { DisplayProcessor, DisplayValue } from '../types/displayValue'; +import { getValueFormat } from '../valueFormats/valueFormats'; +import { getValueMappingResult } from '../utils/valueMappings'; +import { dateTime } from '../datetime'; +import { KeyValue, TimeZone } from '../types'; +import { getScaleCalculator, ScaleCalculator } from './scale'; +import { GrafanaTheme2 } from '../themes/types'; +import { anyToNumber } from '../utils/anyToNumber'; + +interface DisplayProcessorOptions { + field: Partial; + /** + * Will pick browser timezone if not defined + */ + timeZone?: TimeZone; + /** + * Will pick 'dark' if not defined + */ + theme: GrafanaTheme2; +} + +// Reasonable units for time +const timeFormats: KeyValue = { + dateTimeAsIso: true, + dateTimeAsIsoNoDateIfToday: true, + dateTimeAsUS: true, + dateTimeAsUSNoDateIfToday: true, + dateTimeAsLocal: true, + dateTimeAsLocalNoDateIfToday: true, + dateTimeFromNow: true, +}; + +export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayProcessor { + if (!options || isEmpty(options) || !options.field) { + return toStringProcessor; + } + + const field = options.field as Field; + const config = field.config ?? {}; + + let unit = config.unit; + let hasDateUnit = unit && (timeFormats[unit] || unit.startsWith('time:')); + + if (field.type === FieldType.time && !hasDateUnit) { + unit = `dateTimeAsSystem`; + hasDateUnit = true; + } + + const formatFunc = getValueFormat(unit || 'none'); + const scaleFunc = getScaleCalculator(field, options.theme); + const defaultColor = getDefaultColorFunc(field, scaleFunc, options.theme); + + return (value: any) => { + const { mappings } = config; + const isStringUnit = unit === 'string'; + + if (hasDateUnit && typeof value === 'string') { + value = dateTime(value).valueOf(); + } + + let text = toString(value); + let numeric = isStringUnit ? NaN : anyToNumber(value); + let prefix: string | undefined = undefined; + let suffix: string | undefined = undefined; + let color: string | undefined = undefined; + let percent: number | undefined = undefined; + + let shouldFormat = true; + + if (mappings && mappings.length > 0) { + const mappingResult = getValueMappingResult(mappings, value); + + if (mappingResult) { + if (mappingResult.text != null) { + text = mappingResult.text; + } + + if (mappingResult.color != null) { + color = options.theme.visualization.getColorByName(mappingResult.color); + } + + shouldFormat = false; + } + } + + if (!isNaN(numeric)) { + if (shouldFormat && !isBoolean(value)) { + const v = formatFunc(numeric, config.decimals, null, options.timeZone); + text = v.text; + suffix = v.suffix; + prefix = v.prefix; + } + + // Return the value along with scale info + if (color === undefined) { + const scaleResult = scaleFunc(numeric); + color = scaleResult.color; + percent = scaleResult.percent; + } + } + + if (!text) { + if (config.noValue) { + text = config.noValue; + } else { + text = ''; // No data? + } + } + + if (!color) { + const scaleResult = defaultColor(value); + color = scaleResult.color; + percent = scaleResult.percent; + } + + return { text, numeric, prefix, suffix, color, percent }; + }; +} + +function toStringProcessor(value: any): DisplayValue { + return { text: toString(value), numeric: anyToNumber(value) }; +} + +export function getRawDisplayProcessor(): DisplayProcessor { + return (value: any) => ({ + text: `${value}`, + numeric: (null as unknown) as number, + }); +} + +function getDefaultColorFunc(field: Field, scaleFunc: ScaleCalculator, theme: GrafanaTheme2) { + if (field.type === FieldType.string) { + return (value: any) => { + if (!value) { + return { color: theme.colors.background.primary, percent: 0 }; + } + + const hc = strHashCode(value as string); + return { + color: theme.visualization.palette[Math.floor(hc % theme.visualization.palette.length)], + percent: 0, + }; + }; + } + return (value: any) => scaleFunc(-Infinity); +} + +/** + * Converts a string into a numeric value -- we just need it to be different + * enough so that it has a reasonable distribution across a color pallet + */ +function strHashCode(str: string) { + return str.split('').reduce((prevHash, currVal) => ((prevHash << 5) - prevHash + currVal.charCodeAt(0)) | 0, 0); +} diff --git a/packages/grafana-data/src/field/fieldColor.test.ts b/packages/grafana-data/src/field/fieldColor.test.ts new file mode 100644 index 0000000..08eb1a0 --- /dev/null +++ b/packages/grafana-data/src/field/fieldColor.test.ts @@ -0,0 +1,91 @@ +import { createTheme } from '../themes'; +import { Field, FieldColorModeId, FieldType } from '../types'; +import { ArrayVector } from '../vector/ArrayVector'; +import { fieldColorModeRegistry, FieldValueColorCalculator, getFieldSeriesColor } from './fieldColor'; + +function getTestField(mode: string): Field { + return { + name: 'name', + type: FieldType.number, + values: new ArrayVector(), + config: { + color: { + mode: mode, + } as any, + }, + state: {}, + }; +} + +interface GetCalcOptions { + mode: string; + seriesIndex?: number; +} + +function getCalculator(options: GetCalcOptions): FieldValueColorCalculator { + const field = getTestField(options.mode); + const mode = fieldColorModeRegistry.get(options.mode); + field.state!.seriesIndex = options.seriesIndex; + return mode.getCalculator(field, createTheme()); +} + +describe('fieldColorModeRegistry', () => { + it('Schemes should interpolate', () => { + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + expect(calcFn(70, 0.5, undefined)).toEqual('rgb(226, 192, 61)'); + }); + + it('Palette classic with series index 0', () => { + const calcFn = getCalculator({ mode: FieldColorModeId.PaletteClassic, seriesIndex: 0 }); + expect(calcFn(70, 0, undefined)).toEqual('#73BF69'); + }); + + it('Palette classic with series index 1', () => { + const calcFn = getCalculator({ mode: FieldColorModeId.PaletteClassic, seriesIndex: 1 }); + expect(calcFn(70, 0, undefined)).toEqual('#F2CC0C'); + }); + + it('When color.seriesBy is set to last use that instead of v', () => { + const field = getTestField('continuous-GrYlRd'); + + field.config.color!.seriesBy = 'last'; + // min = -10, max = 10, last = 5 + // last percent 75% + field.values = new ArrayVector([0, -10, 5, 10, 2, 5]); + + const color = getFieldSeriesColor(field, createTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + expect(color.color).toEqual(calcFn(4, 0.75)); + }); +}); + +describe('getFieldSeriesColor', () => { + const field = getTestField('continuous-GrYlRd'); + field.values = new ArrayVector([0, -10, 5, 10, 2, 5]); + + it('When color.seriesBy is last use that to calc series color', () => { + field.config.color!.seriesBy = 'last'; + const color = getFieldSeriesColor(field, createTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + // the 4 can be anything, 0.75 comes from 5 being 75% in the range -10 to 10 (see data above) + expect(color.color).toEqual(calcFn(4, 0.75)); + }); + + it('When color.seriesBy is max use that to calc series color', () => { + field.config.color!.seriesBy = 'max'; + const color = getFieldSeriesColor(field, createTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + expect(color.color).toEqual(calcFn(10, 1)); + }); + + it('When color.seriesBy is min use that to calc series color', () => { + field.config.color!.seriesBy = 'min'; + const color = getFieldSeriesColor(field, createTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + expect(color.color).toEqual(calcFn(-10, 0)); + }); +}); diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts new file mode 100644 index 0000000..4432301 --- /dev/null +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -0,0 +1,237 @@ +import { FALLBACK_COLOR, Field, FieldColorModeId, Threshold } from '../types'; +import { RegistryItem } from '../utils'; +import { Registry } from '../utils/Registry'; +import { interpolateRgbBasis } from 'd3-interpolate'; +import { fallBackTreshold } from './thresholds'; +import { getScaleCalculator, ColorScaleValue } from './scale'; +import { reduceField } from '../transformations/fieldReducer'; +import { GrafanaTheme2 } from '../themes/types'; + +/** @beta */ +export type FieldValueColorCalculator = (value: number, percent: number, Threshold?: Threshold) => string; + +/** @beta */ +export interface FieldColorMode extends RegistryItem { + getCalculator: (field: Field, theme: GrafanaTheme2) => FieldValueColorCalculator; + getColors?: (theme: GrafanaTheme2) => string[]; + isContinuous?: boolean; + isByValue?: boolean; +} + +/** @internal */ +export const fieldColorModeRegistry = new Registry(() => { + return [ + { + id: FieldColorModeId.Fixed, + name: 'Single color', + description: 'Set a specific color', + getCalculator: getFixedColor, + }, + { + id: FieldColorModeId.Thresholds, + name: 'From thresholds', + isByValue: true, + description: 'Derive colors from thresholds', + getCalculator: (_field, theme) => { + return (_value, _percent, threshold) => { + const thresholdSafe = threshold ?? fallBackTreshold; + return theme.visualization.getColorByName(thresholdSafe.color); + }; + }, + }, + new FieldColorSchemeMode({ + id: FieldColorModeId.PaletteClassic, + name: 'Classic palette', + isContinuous: false, + isByValue: false, + getColors: (theme: GrafanaTheme2) => { + return theme.visualization.palette; + }, + }), + new FieldColorSchemeMode({ + id: 'continuous-GrYlRd', + name: 'Green-Yellow-Red', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['green', 'yellow', 'red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-RdYlGr', + name: 'Red-Yellow-Green', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['red', 'yellow', 'green'], + }), + new FieldColorSchemeMode({ + id: 'continuous-BlYlRd', + name: 'Blue-Yellow-Red', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['dark-blue', 'super-light-yellow', 'dark-red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-YlRd', + name: 'Yellow-Red', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-BlPu', + name: 'Blue-Purple', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['blue', 'purple'], + }), + new FieldColorSchemeMode({ + id: 'continuous-YlBl', + name: 'Yellow-Blue', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-blue'], + }), + new FieldColorSchemeMode({ + id: 'continuous-blues', + name: 'Blues', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-blue'], + }), + new FieldColorSchemeMode({ + id: 'continuous-reds', + name: 'Reds', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-greens', + name: 'Greens', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-green'], + }), + new FieldColorSchemeMode({ + id: 'continuous-purples', + name: 'Purples', + isContinuous: true, + isByValue: true, + getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-purple'], + }), + ]; +}); + +interface FieldColorSchemeModeOptions { + id: string; + name: string; + description?: string; + getColors: (theme: GrafanaTheme2) => string[]; + isContinuous: boolean; + isByValue: boolean; +} + +export class FieldColorSchemeMode implements FieldColorMode { + id: string; + name: string; + description?: string; + isContinuous: boolean; + isByValue: boolean; + colorCache?: string[]; + colorCacheTheme?: GrafanaTheme2; + interpolator?: (value: number) => string; + getNamedColors?: (theme: GrafanaTheme2) => string[]; + + constructor(options: FieldColorSchemeModeOptions) { + this.id = options.id; + this.name = options.name; + this.description = options.description; + this.getNamedColors = options.getColors; + this.isContinuous = options.isContinuous; + this.isByValue = options.isByValue; + } + + getColors(theme: GrafanaTheme2): string[] { + if (!this.getNamedColors) { + return []; + } + + if (this.colorCache && this.colorCacheTheme === theme) { + return this.colorCache; + } + + this.colorCache = this.getNamedColors(theme).map(theme.visualization.getColorByName); + this.colorCacheTheme = theme; + + return this.colorCache; + } + + private getInterpolator() { + if (!this.interpolator) { + this.interpolator = interpolateRgbBasis(this.colorCache!); + } + + return this.interpolator; + } + + getCalculator(field: Field, theme: GrafanaTheme2) { + const colors = this.getColors(theme); + + if (this.isByValue) { + if (this.isContinuous) { + return (_: number, percent: number, _threshold?: Threshold) => { + return this.getInterpolator()(percent); + }; + } else { + return (_: number, percent: number, _threshold?: Threshold) => { + return colors[percent * (colors.length - 1)]; + }; + } + } else { + const seriesIndex = field.state?.seriesIndex ?? 0; + + return (_: number, _percent: number, _threshold?: Threshold) => { + return colors[seriesIndex % colors.length]; + }; + } + } +} + +/** @beta */ +export function getFieldColorModeForField(field: Field): FieldColorMode { + return fieldColorModeRegistry.get(field.config.color?.mode ?? FieldColorModeId.Thresholds); +} + +/** @beta */ +export function getFieldColorMode(mode?: FieldColorModeId | string): FieldColorMode { + return fieldColorModeRegistry.get(mode ?? FieldColorModeId.Thresholds); +} + +/** + * @alpha + * Function that will return a series color for any given color mode. If the color mode is a by value color + * mode it will use the field.config.color.seriesBy property to figure out which value to use + */ +export function getFieldSeriesColor(field: Field, theme: GrafanaTheme2): ColorScaleValue { + const mode = getFieldColorModeForField(field); + + if (!mode.isByValue) { + return { + color: mode.getCalculator(field, theme)(0, 0), + threshold: fallBackTreshold, + percent: 1, + }; + } + + const scale = getScaleCalculator(field, theme); + const stat = field.config.color?.seriesBy ?? 'last'; + const calcs = reduceField({ field, reducers: [stat] }); + const value = calcs[stat] ?? 0; + + return scale(value); +} + +function getFixedColor(field: Field, theme: GrafanaTheme2) { + return () => { + return theme.visualization.getColorByName(field.config.color?.fixedColor ?? FALLBACK_COLOR); + }; +} diff --git a/packages/grafana-data/src/field/fieldComparers.ts b/packages/grafana-data/src/field/fieldComparers.ts new file mode 100644 index 0000000..1eab766 --- /dev/null +++ b/packages/grafana-data/src/field/fieldComparers.ts @@ -0,0 +1,112 @@ +import { Field, FieldType } from '../types/dataFrame'; +import { Vector } from '../types/vector'; +import { dateTime } from '../datetime'; +import { isNumber } from 'lodash'; + +type IndexComparer = (a: number, b: number) => number; + +/** @public */ +export const fieldIndexComparer = (field: Field, reverse = false): IndexComparer => { + const values = field.values; + + switch (field.type) { + case FieldType.number: + return numericIndexComparer(values, reverse); + case FieldType.string: + return stringIndexComparer(values, reverse); + case FieldType.boolean: + return booleanIndexComparer(values, reverse); + case FieldType.time: + return timeIndexComparer(values, reverse); + default: + return naturalIndexComparer(reverse); + } +}; + +/** @public */ +export const timeComparer = (a: any, b: any): number => { + if (!a || !b) { + return falsyComparer(a, b); + } + + if (isNumber(a) && isNumber(b)) { + return numericComparer(a, b); + } + + if (dateTime(a).isBefore(b)) { + return -1; + } + + if (dateTime(b).isBefore(a)) { + return 1; + } + + return 0; +}; + +/** @public */ +export const numericComparer = (a: number, b: number): number => { + return a - b; +}; + +/** @public */ +export const stringComparer = (a: string, b: string): number => { + if (!a || !b) { + return falsyComparer(a, b); + } + return a.localeCompare(b); +}; + +export const booleanComparer = (a: boolean, b: boolean): number => { + return falsyComparer(a, b); +}; + +const falsyComparer = (a: any, b: any): number => { + if (!a && b) { + return 1; + } + + if (a && !b) { + return -1; + } + + return 0; +}; + +const timeIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA = values.get(a); + const vB = values.get(b); + return reverse ? timeComparer(vB, vA) : timeComparer(vA, vB); + }; +}; + +const booleanIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA: boolean = values.get(a); + const vB: boolean = values.get(b); + return reverse ? booleanComparer(vB, vA) : booleanComparer(vA, vB); + }; +}; + +const numericIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA: number = values.get(a); + const vB: number = values.get(b); + return reverse ? numericComparer(vB, vA) : numericComparer(vA, vB); + }; +}; + +const stringIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA: string = values.get(a); + const vB: string = values.get(b); + return reverse ? stringComparer(vB, vA) : stringComparer(vA, vB); + }; +}; + +const naturalIndexComparer = (reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + return reverse ? numericComparer(b, a) : numericComparer(a, b); + }; +}; diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts new file mode 100644 index 0000000..f66c47b --- /dev/null +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -0,0 +1,369 @@ +import { merge } from 'lodash'; +import { getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay'; +import { toDataFrame } from '../dataframe/processDataFrame'; +import { ReducerID } from '../transformations/fieldReducer'; +import { MappingType, SpecialValueMatch, ValueMapping } from '../types'; +import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; +import { createTheme } from '../themes'; + +describe('FieldDisplay', () => { + beforeAll(() => { + // Since FieldConfigEditors belong to grafana-ui we need to mock those here + // as grafana-ui code cannot be imported in grafana-data. + // TODO: figure out a way to share standard editors between data/ui tests + const mappings = { + id: 'mappings', // Match field properties + process: (value: any) => value, + shouldApply: () => true, + } as any; + + standardFieldConfigEditorRegistry.setInit(() => { + return [mappings]; + }); + }); + + it('show first numeric values', () => { + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.first], + }, + fieldConfig: { + overrides: [], + defaults: { + displayName: '$__cell_0 * $__field_name * $__series_name', + }, + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.text)).toEqual(['1', '2']); + }); + + it('show last numeric values', () => { + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.last], + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.numeric)).toEqual([5, 6]); + }); + + it('show all numeric values', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, // + limit: 1000, + calcs: [], + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.numeric)).toEqual([1, 3, 5, 2, 4, 6]); + }); + + it('show 2 numeric values (limit)', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, // + limit: 2, + calcs: [], + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.numeric)).toEqual([1, 3]); // First 2 are from the first field + }); + + it('should not calculate min max if ensureGlobalRange is false', () => { + const options = createDisplayOptions({ + ensureGlobalRange: false, + reduceOptions: { + values: true, // + limit: 1000, + calcs: [], + }, + }); + const display = getFieldDisplayValues(options); + expect(display[0].field.min).toBeUndefined(); + expect(display[0].field.max).toBeUndefined(); + }); + + it('should ensure global min / max on numerical fields', () => { + const options = createDisplayOptions({ + ensureGlobalRange: true, + reduceOptions: { + values: true, // + limit: 1000, + calcs: [], + }, + }); + const display = getFieldDisplayValues(options); + expect(display[0].field.min).toEqual(1); + expect(display[0].field.max).toEqual(6); + }); + + it('Should return field thresholds when there is no data', () => { + const options = createEmptyDisplayOptions({ + fieldConfig: { + defaults: { + thresholds: { steps: [{ color: '#F2495C', value: 50 }] }, + }, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].field.thresholds!.steps!.length).toEqual(1); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should return field with default text when no mapping or data available', () => { + const options = createEmptyDisplayOptions(); + const display = getFieldDisplayValues(options); + expect(display[0].display.text).toEqual('No data'); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should return field mapped value when there is no data', () => { + const mapEmptyToText = '0'; + const options = createEmptyDisplayOptions({ + fieldConfig: { + defaults: { + mappings: [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: mapEmptyToText }, + }, + }, + ], + }, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].display.text).toEqual(mapEmptyToText); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should always return display numeric 0 when there is no data', () => { + const mapEmptyToText = '0'; + const options = createEmptyDisplayOptions({ + fieldConfig: { + overrides: { + mappings: [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: mapEmptyToText }, + }, + }, + ], + }, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should always return defaults with min/max 0 when there is no data', () => { + const options = createEmptyDisplayOptions({ + fieldConfig: { + defaults: {}, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].field.min).toEqual(0); + expect(display[0].field.max).toEqual(0); + }); + + describe('Value mapping', () => { + it('should apply value mapping', () => { + const mappingConfig: ValueMapping[] = [ + { + type: MappingType.ValueToText, + options: { + '1': { text: 'Value mapped to text' }, + }, + }, + ]; + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.first], + }, + }); + + options.data![0].fields[1]!.config = { mappings: mappingConfig }; + options.data![0].fields[2]!.config = { mappings: mappingConfig }; + + const result = getFieldDisplayValues(options); + expect(result[0].display.text).toEqual('Value mapped to text'); + }); + + it('should apply range value mapping', () => { + const mappedValue = 'Range mapped to text'; + const mappingConfig: ValueMapping[] = [ + { + type: MappingType.RangeToText, + options: { + from: 1, + to: 3, + result: { text: mappedValue }, + }, + }, + ]; + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.first], + values: true, + }, + }); + + options.data![0].fields[1]!.config = { mappings: mappingConfig }; + options.data![0].fields[2]!.config = { mappings: mappingConfig }; + + const result = getFieldDisplayValues(options); + + expect(result[0].display.text).toEqual(mappedValue); + expect(result[2].display.text).toEqual('5'); + expect(result[3].display.text).toEqual(mappedValue); + }); + }); + + describe('auto option', () => { + it('No string fields, single value', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, + calcs: [], + }, + data: [ + toDataFrame({ + name: 'Series Name', + fields: [{ name: 'A', values: [10] }], + }), + ], + }); + + const result = getFieldDisplayValues(options); + expect(result[0].display.title).toEqual('A'); + expect(result[0].display.text).toEqual('10'); + }); + + it('Single other string field', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, + calcs: [], + }, + data: [ + toDataFrame({ + fields: [ + { name: 'Name', values: ['A', 'B'] }, + { name: 'Value', values: [10, 20] }, + ], + }), + ], + }); + + const result = getFieldDisplayValues(options); + expect(result[0].display.title).toEqual('A'); + expect(result[0].display.text).toEqual('10'); + expect(result[1].display.title).toEqual('B'); + expect(result[1].display.text).toEqual('20'); + }); + + it('Single string field multiple value fields', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, + calcs: [], + }, + data: [ + toDataFrame({ + fields: [ + { name: 'Name', values: ['A', 'B'] }, + { name: 'SensorA', values: [10, 20] }, + { name: 'SensorB', values: [10, 20] }, + ], + }), + ], + }); + + const result = getFieldDisplayValues(options); + expect(result[0].display.title).toEqual('A SensorA'); + expect(result[0].display.text).toEqual('10'); + expect(result[1].display.title).toEqual('B SensorA'); + expect(result[1].display.text).toEqual('20'); + expect(result[2].display.title).toEqual('A SensorB'); + expect(result[3].display.title).toEqual('B SensorB'); + }); + + it('Multiple other string fields', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, + calcs: [], + }, + data: [ + toDataFrame({ + fields: [ + { name: 'Country', values: ['Sweden', 'Norway'] }, + { name: 'City', values: ['Stockholm', 'Oslo'] }, + { name: 'Value', values: [10, 20] }, + ], + }), + ], + }); + + const result = getFieldDisplayValues(options); + expect(result[0].display.title).toEqual('Sweden Stockholm'); + expect(result[0].display.text).toEqual('10'); + expect(result[1].display.title).toEqual('Norway Oslo'); + expect(result[1].display.text).toEqual('20'); + }); + }); +}); + +function createEmptyDisplayOptions(extend = {}): GetFieldDisplayValuesOptions { + const options = createDisplayOptions(extend); + + return Object.assign(options, { + data: [ + { + name: 'No data', + fields: [], + length: 0, + }, + ], + }); +} + +function createDisplayOptions(extend: Partial = {}): GetFieldDisplayValuesOptions { + const options: GetFieldDisplayValuesOptions = { + data: [ + toDataFrame({ + name: 'Series Name', + fields: [ + { name: 'Field 1', values: ['a', 'b', 'c'] }, + { name: 'Field 2', values: [1, 3, 5] }, + { name: 'Field 3', values: [2, 4, 6] }, + ], + }), + ], + replaceVariables: (value: string) => { + return value; + }, + reduceOptions: { + calcs: [], + }, + fieldConfig: { + overrides: [], + defaults: {}, + }, + theme: createTheme(), + }; + + return merge(options, extend); +} diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts new file mode 100644 index 0000000..1fea836 --- /dev/null +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -0,0 +1,353 @@ +import { toString, isEmpty } from 'lodash'; + +import { getDisplayProcessor } from './displayProcessor'; +import { + DataFrame, + DisplayValue, + DisplayValueAlignmentFactors, + Field, + FieldConfig, + FieldConfigSource, + FieldType, + InterpolateFunction, + LinkModel, + TimeRange, + TimeZone, +} from '../types'; +import { DataFrameView } from '../dataframe/DataFrameView'; +import { GrafanaTheme2 } from '../themes'; +import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { ScopedVars } from '../types/ScopedVars'; +import { getTimeField } from '../dataframe/processDataFrame'; +import { getFieldMatcher } from '../transformations'; +import { FieldMatcherID } from '../transformations/matchers/ids'; +import { getFieldDisplayName } from './fieldState'; +import { ensureGlobalRangeOnState } from './scale'; + +/** + * Options for how to turn DataFrames into an array of display values + */ +export interface ReduceDataOptions { + /* If true show each row value */ + values?: boolean; + /** if showing all values limit */ + limit?: number; + /** When !values, pick one value for the whole field */ + calcs: string[]; + /** Which fields to show. By default this is only numeric fields */ + fields?: string; +} + +// TODO: use built in variables, same as for data links? +export const VAR_SERIES_NAME = '__series.name'; +export const VAR_FIELD_NAME = '__field.displayName'; // Includes the rendered tags and naming strategy +export const VAR_FIELD_LABELS = '__field.labels'; +export const VAR_CALC = '__calc'; +export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates + +export interface FieldSparkline { + y: Field; // Y values + x?: Field; // if this does not exist, use the index + timeRange?: TimeRange; // Optionally force an absolute time + highlightIndex?: number; +} + +export interface FieldDisplay { + name: string; // The field name (title is in display) + field: FieldConfig; + display: DisplayValue; + sparkline?: FieldSparkline; + + // Expose to the original values for delayed inspection (DataLinks etc) + view?: DataFrameView; + colIndex?: number; // The field column index + rowIndex?: number; // only filled in when the value is from a row (ie, not a reduction) + getLinks?: () => LinkModel[]; + hasLinks: boolean; +} + +export interface GetFieldDisplayValuesOptions { + data?: DataFrame[]; + reduceOptions: ReduceDataOptions; + fieldConfig: FieldConfigSource; + replaceVariables: InterpolateFunction; + sparkline?: boolean; // Calculate the sparkline + theme: GrafanaTheme2; + timeZone?: TimeZone; + ensureGlobalRange?: boolean; +} + +export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25; + +export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => { + const { replaceVariables, reduceOptions, timeZone } = options; + const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last]; + + const values: FieldDisplay[] = []; + const fieldMatcher = getFieldMatcher( + reduceOptions.fields + ? { + id: FieldMatcherID.byRegexp, + options: reduceOptions.fields, + } + : { + id: FieldMatcherID.numeric, + } + ); + + const data = options.data ?? []; + const limit = reduceOptions.limit ? reduceOptions.limit : DEFAULT_FIELD_DISPLAY_VALUES_LIMIT; + const scopedVars: ScopedVars = {}; + + let hitLimit = false; + + if (options.ensureGlobalRange) { + ensureGlobalRangeOnState(data); + } + + for (let s = 0; s < data.length && !hitLimit; s++) { + const dataFrame = data[s]; // Name is already set + + const { timeField } = getTimeField(dataFrame); + const view = new DataFrameView(dataFrame); + + for (let i = 0; i < dataFrame.fields.length && !hitLimit; i++) { + const field = dataFrame.fields[i]; + const fieldLinksSupplier = field.getLinks; + + // To filter out time field, need an option for this + if (!fieldMatcher(field, dataFrame, data)) { + continue; + } + + let config = field.config; // already set by the prepare task + + if (field.state?.range) { + // Us the global min/max values + config = { + ...config, + ...field.state?.range, + }; + } + + // const displayName = getFieldDisplayName(field, dataFrame, data); + const displayName = field.config.displayName ?? ''; + + const display = + field.display ?? + getDisplayProcessor({ + field, + theme: options.theme, + timeZone, + }); + + // Show all rows + if (reduceOptions.values) { + const usesCellValues = displayName.indexOf(VAR_CELL_PREFIX) >= 0; + + for (let j = 0; j < field.values.length; j++) { + // Add all the row variables + if (usesCellValues) { + for (let k = 0; k < dataFrame.fields.length; k++) { + const f = dataFrame.fields[k]; + const v = f.values.get(j); + scopedVars[VAR_CELL_PREFIX + k] = { + value: v, + text: toString(v), + }; + } + } + + const displayValue = display(field.values.get(j)); + + if (displayName !== '') { + displayValue.title = replaceVariables(displayName, { + ...field.state?.scopedVars, // series and field scoped vars + ...scopedVars, + }); + } else { + displayValue.title = getSmartDisplayNameForRow(dataFrame, field, j); + } + + values.push({ + name: '', + field: config, + display: displayValue, + view, + colIndex: i, + rowIndex: j, + getLinks: fieldLinksSupplier + ? () => + fieldLinksSupplier({ + valueRowIndex: j, + }) + : () => [], + hasLinks: hasLinks(field), + }); + + if (values.length >= limit) { + hitLimit = true; + break; + } + } + } else { + const results = reduceField({ + field, + reducers: calcs, // The stats to calculate + }); + + for (const calc of calcs) { + scopedVars[VAR_CALC] = { value: calc, text: calc }; + const displayValue = display(results[calc]); + + if (displayName !== '') { + displayValue.title = replaceVariables(displayName, { + ...field.state?.scopedVars, // series and field scoped vars + ...scopedVars, + }); + } else { + displayValue.title = getFieldDisplayName(field, dataFrame, data); + } + + let sparkline: FieldSparkline | undefined = undefined; + if (options.sparkline) { + sparkline = { + y: dataFrame.fields[i], + x: timeField, + }; + if (calc === ReducerID.last) { + sparkline.highlightIndex = sparkline.y.values.length - 1; + } else if (calc === ReducerID.first) { + sparkline.highlightIndex = 0; + } + } + + values.push({ + name: calc, + field: config, + display: displayValue, + sparkline, + view, + colIndex: i, + getLinks: fieldLinksSupplier + ? () => + fieldLinksSupplier({ + calculatedValue: displayValue, + }) + : () => [], + hasLinks: hasLinks(field), + }); + } + } + } + } + + if (values.length === 0) { + values.push(createNoValuesFieldDisplay(options)); + } + + return values; +}; + +function getSmartDisplayNameForRow(frame: DataFrame, field: Field, rowIndex: number): string { + let parts: string[] = []; + let otherNumericFields = 0; + + for (const otherField of frame.fields) { + if (otherField === field) { + continue; + } + + if (otherField.type === FieldType.string) { + const value = otherField.values.get(rowIndex) ?? ''; + if (value.length > 0) { + parts.push(value); + } + } else if (otherField.type === FieldType.number) { + otherNumericFields++; + } + } + + if (otherNumericFields || parts.length === 0) { + parts.push(getFieldDisplayName(field)); + } + + return parts.join(' '); +} + +export function hasLinks(field: Field): boolean { + return field.config?.links?.length ? field.config.links.length > 0 : false; +} + +export function getDisplayValueAlignmentFactors(values: FieldDisplay[]): DisplayValueAlignmentFactors { + const info: DisplayValueAlignmentFactors = { + title: '', + text: '', + }; + + let prefixLength = 0; + let suffixLength = 0; + + for (let i = 0; i < values.length; i++) { + const v = values[i].display; + + if (v.text && v.text.length > info.text.length) { + info.text = v.text; + } + + if (v.title && v.title.length > info.title.length) { + info.title = v.title; + } + + if (v.prefix && v.prefix.length > prefixLength) { + info.prefix = v.prefix; + prefixLength = v.prefix.length; + } + + if (v.suffix && v.suffix.length > suffixLength) { + info.suffix = v.suffix; + suffixLength = v.suffix.length; + } + } + return info; +} + +function createNoValuesFieldDisplay(options: GetFieldDisplayValuesOptions): FieldDisplay { + const displayName = 'No data'; + const { fieldConfig, timeZone } = options; + const { defaults } = fieldConfig; + + const displayProcessor = getDisplayProcessor({ + field: { + type: FieldType.other, + config: defaults, + }, + theme: options.theme, + timeZone, + }); + + const display = displayProcessor(null); + const text = getDisplayText(display, displayName); + + return { + name: displayName, + field: { + ...defaults, + max: defaults.max ?? 0, + min: defaults.min ?? 0, + }, + display: { + text, + numeric: 0, + color: display.color, + }, + hasLinks: false, + }; +} + +function getDisplayText(display: DisplayValue, fallback: string): string { + if (!display || isEmpty(display.text)) { + return fallback; + } + return display.text; +} diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts new file mode 100644 index 0000000..dd504f2 --- /dev/null +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -0,0 +1,799 @@ +import { + applyFieldOverrides, + applyRawFieldOverrides, + FieldOverrideEnv, + findNumericFieldMinMax, + getLinksSupplier, + setDynamicConfigValue, + setFieldConfigDefaults, +} from './fieldOverrides'; +import { ArrayDataFrame, MutableDataFrame, toDataFrame } from '../dataframe'; +import { + DataFrame, + Field, + FieldColorModeId, + FieldConfig, + FieldConfigPropertyItem, + FieldConfigSource, + FieldType, + InterpolateFunction, + ScopedVars, + ThresholdsMode, +} from '../types'; +import { locationUtil, Registry } from '../utils'; +import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; +import { FieldMatcherID } from '../transformations'; +import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +import { getFieldDisplayName } from './fieldState'; +import { ArrayVector } from '../vector'; +import { getDisplayProcessor } from './displayProcessor'; +import { createTheme } from '../themes'; + +const property1: any = { + id: 'custom.property1', // Match field properties + path: 'property1', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => true, +}; + +const property2 = { + id: 'custom.property2', // Match field properties + path: 'property2', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => true, +}; + +const property3: any = { + id: 'custom.property3.nested', // Match field properties + path: 'property3.nested', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => true, +}; + +const shouldApplyFalse: any = { + id: 'custom.shouldApplyFalse', // Match field properties + path: 'shouldApplyFalse', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => false, +}; + +export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry(() => { + return [property1, property2, property3, shouldApplyFalse, ...mockStandardProperties()]; +}); + +locationUtil.initialize({ + config: { appSubUrl: '/subUrl' } as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, +}); + +describe('Global MinMax', () => { + it('find global min max', () => { + const f0 = new ArrayDataFrame<{ title: string; value: number; value2: number | null }>([ + { title: 'AAA', value: 100, value2: 1234 }, + { title: 'BBB', value: -20, value2: null }, + { title: 'CCC', value: 200, value2: 1000 }, + ]); + + const minmax = findNumericFieldMinMax([f0]); + expect(minmax.min).toEqual(-20); + expect(minmax.max).toEqual(1234); + }); + + it('find global min max when all values are zero', () => { + const f0 = new ArrayDataFrame<{ title: string; value: number; value2: number | null }>([ + { title: 'AAA', value: 0, value2: 0 }, + { title: 'CCC', value: 0, value2: 0 }, + ]); + + const minmax = findNumericFieldMinMax([f0]); + expect(minmax.min).toEqual(0); + expect(minmax.max).toEqual(0); + }); + + describe('when value is null', () => { + it('then global min max should be null', () => { + const frame = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1] }, + { name: 'Value', type: FieldType.number, values: [null] }, + ], + }); + const { min, max } = findNumericFieldMinMax([frame]); + + expect(min).toBe(null); + expect(max).toBe(null); + }); + }); + + describe('when value values are zeo', () => { + it('then global min max should be correct', () => { + const frame = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Value', type: FieldType.number, values: [1, 2] }, + ], + }); + const frame2 = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Value', type: FieldType.number, values: [0, 0] }, + ], + }); + + const { min, max } = findNumericFieldMinMax([frame, frame2]); + + expect(min).toBe(0); + expect(max).toBe(2); + }); + }); +}); + +describe('applyFieldOverrides', () => { + const f0 = new ArrayDataFrame<{ title: string; value: number; value2: number | null }>([ + { title: 'AAA', value: 100, value2: 1234 }, + { title: 'BBB', value: -20, value2: null }, + { title: 'CCC', value: 200, value2: 1000 }, + ]); + + // Hardcode the max value + f0.fields[1].config.max = 0; + f0.fields[1].config.decimals = 6; + + const src: FieldConfigSource = { + defaults: { + unit: 'xyz', + decimals: 2, + links: [{ title: 'link', url: '${__value.text}' }], + }, + overrides: [ + { + matcher: { id: FieldMatcherID.numeric }, + properties: [ + { id: 'decimals', value: 1 }, // Numeric + { id: 'displayName', value: 'Kittens' }, // Text + ], + }, + ], + }; + + describe('given multiple data frames', () => { + const f0 = new MutableDataFrame({ + name: 'A', + fields: [{ name: 'message', type: FieldType.string, values: [10, 20] }], + }); + const f1 = new MutableDataFrame({ + name: 'B', + fields: [{ name: 'info', type: FieldType.string, values: [10, 20] }], + }); + + it('should add scopedVars to fields', () => { + const withOverrides = applyFieldOverrides({ + data: [f0, f1], + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (value: any) => value, + theme: createTheme(), + fieldConfigRegistry: new FieldConfigOptionsRegistry(), + }); + + expect(withOverrides[0].fields[0].state!.scopedVars).toMatchInlineSnapshot(` + Object { + "__field": Object { + "text": "Field", + "value": Object {}, + }, + "__series": Object { + "text": "Series", + "value": Object { + "name": "A", + }, + }, + } + `); + + expect(withOverrides[1].fields[0].state!.scopedVars).toMatchInlineSnapshot(` + Object { + "__field": Object { + "text": "Field", + "value": Object {}, + }, + "__series": Object { + "text": "Series", + "value": Object { + "name": "B", + }, + }, + } + `); + }); + }); + + it('will merge FieldConfig with default values', () => { + const field: FieldConfig = { + min: 0, + max: 100, + }; + + const f1 = { + unit: 'ms', + dateFormat: '', // should be ignored + max: parseFloat('NOPE'), // should be ignored + min: null, // should alo be ignored! + displayName: 'newTitle', + }; + + const f: DataFrame = toDataFrame({ + fields: [{ type: FieldType.number, name: 'x', config: field, values: [] }], + }); + + const processed = applyFieldOverrides({ + data: [f], + fieldConfig: { + defaults: f1 as FieldConfig, + overrides: [], + }, + fieldConfigRegistry: customFieldRegistry, + replaceVariables: (v) => v, + theme: createTheme(), + })[0]; + + const outField = processed.fields[0]; + + expect(outField.config.min).toEqual(0); + expect(outField.config.max).toEqual(100); + expect(outField.config.unit).toEqual('ms'); + expect(getFieldDisplayName(outField, f)).toEqual('newTitle'); + }); + + it('will apply field overrides', () => { + const data = applyFieldOverrides({ + data: [f0], // the frame + fieldConfig: src as FieldConfigSource, // defaults + overrides + replaceVariables: (undefined as any) as InterpolateFunction, + theme: createTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + const valueColumn = data.fields[1]; + const config = valueColumn.config; + + // Keep max from the original setting + expect(config.max).toEqual(0); + + // Don't Automatically pick the min value + expect(config.min).toEqual(undefined); + + // The default value applied + expect(config.unit).toEqual('xyz'); + + // The default value applied + expect(config.displayName).toEqual('Kittens'); + + // The override applied + expect(config.decimals).toEqual(1); + }); + + it('will apply set min/max when asked', () => { + const data = applyFieldOverrides({ + data: [f0], // the frame + fieldConfig: src as FieldConfigSource, // defaults + overrides + replaceVariables: (undefined as any) as InterpolateFunction, + theme: createTheme(), + })[0]; + const valueColumn = data.fields[1]; + const range = valueColumn.state!.range!; + + // Keep max from the original setting + expect(range.max).toEqual(0); + + // Don't Automatically pick the min value + expect(range.min).toEqual(-20); + }); + + it('getLinks should use applied field config', () => { + const replaceVariablesCalls: any[] = []; + + const data = applyFieldOverrides({ + data: [f0], // the frame + fieldConfig: src as FieldConfigSource, // defaults + overrides + replaceVariables: ((value: string, variables: ScopedVars) => { + replaceVariablesCalls.push(variables); + return value; + }) as InterpolateFunction, + theme: createTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + + data.fields[1].getLinks!({ valueRowIndex: 0 }); + + expect(data.fields[1].config.decimals).toEqual(1); + expect(replaceVariablesCalls[0].__value.value.text).toEqual('100.0'); + }); +}); + +describe('setFieldConfigDefaults', () => { + it('applies field config defaults', () => { + const dsFieldConfig: FieldConfig = { + decimals: 2, + min: 0, + max: 100, + }; + + const panelFieldConfig: FieldConfig = { + decimals: 1, + min: 10, + max: 50, + unit: 'km', + }; + + const context: FieldOverrideEnv = { + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + fieldConfigRegistry: customFieldRegistry, + }; + + // we mutate dsFieldConfig + setFieldConfigDefaults(dsFieldConfig, panelFieldConfig, context); + + expect(dsFieldConfig).toMatchInlineSnapshot(` + Object { + "custom": Object {}, + "decimals": 2, + "max": 100, + "min": 0, + "unit": "km", + } + `); + }); + + it('applies field config defaults for custom properties', () => { + const dsFieldConfig: FieldConfig = { + custom: { + property1: 10, + }, + }; + + const panelFieldConfig: FieldConfig = { + custom: { + property1: 20, + property2: 10, + }, + }; + + const context: FieldOverrideEnv = { + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + fieldConfigRegistry: customFieldRegistry, + }; + + // we mutate dsFieldConfig + setFieldConfigDefaults(dsFieldConfig, panelFieldConfig, context); + + expect(dsFieldConfig).toMatchInlineSnapshot(` + Object { + "custom": Object { + "property1": 10, + "property2": 10, + }, + } + `); + }); +}); + +describe('setDynamicConfigValue', () => { + it('applies dynamic config values', () => { + const config = { + displayName: 'test', + }; + + setDynamicConfigValue( + config, + { + id: 'displayName', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.displayName).toEqual('applied'); + }); + + it('applies custom dynamic config values', () => { + const config = { + custom: { + property1: 1, + }, + }; + setDynamicConfigValue( + config, + { + id: 'custom.property1', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.property1).toEqual('applied'); + }); + + it('applies overrides even when shouldApply returns false', () => { + const config: FieldConfig = { + custom: {}, + }; + setDynamicConfigValue( + config, + { + id: 'custom.shouldApplyFalse', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.shouldApplyFalse).toEqual('applied'); + }); + + it('applies nested custom dynamic config values', () => { + const config = { + custom: { + property3: { + nested: 1, + }, + }, + }; + setDynamicConfigValue( + config, + { + id: 'custom.property3.nested', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.property3.nested).toEqual('applied'); + }); + + it('removes properties', () => { + const config = { + displayName: 'title', + custom: { + property3: { + nested: 1, + }, + }, + }; + setDynamicConfigValue( + config, + { + id: 'custom.property3.nested', + value: undefined, + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + setDynamicConfigValue( + config, + { + id: 'displayName', + value: undefined, + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.property3).toEqual({}); + expect(config.displayName).toBeUndefined(); + }); +}); + +describe('getLinksSupplier', () => { + it('will replace variables in url and title of the data link', () => { + locationUtil.initialize({ + config: {} as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + + const f0 = new MutableDataFrame({ + name: 'A', + fields: [ + { + name: 'message', + type: FieldType.string, + values: [10, 20], + config: { + links: [ + { + url: 'url to be interpolated', + title: 'title to be interpolated', + }, + ], + }, + }, + ], + }); + + const replaceSpy = jest.fn(); + const supplier = getLinksSupplier(f0, f0.fields[0], {}, replaceSpy); + supplier({}); + + expect(replaceSpy).toBeCalledTimes(2); + expect(replaceSpy.mock.calls[0][0]).toEqual('url to be interpolated'); + expect(replaceSpy.mock.calls[1][0]).toEqual('title to be interpolated'); + }); + + it('handles internal links', () => { + locationUtil.initialize({ + config: { appSubUrl: '' } as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + + const f0 = new MutableDataFrame({ + name: 'A', + fields: [ + { + name: 'message', + type: FieldType.string, + values: [10, 20], + config: { + links: [ + { + url: '', + title: '', + internal: { + datasourceUid: '0', + datasourceName: 'testDS', + query: '12345', + }, + }, + ], + }, + display: (v) => ({ numeric: v, text: String(v) }), + }, + ], + }); + + const supplier = getLinksSupplier( + f0, + f0.fields[0], + {}, + // We do not need to interpolate anything for this test + (value, vars, format) => value + ); + + const links = supplier({ valueRowIndex: 0 }); + + expect(links.length).toBe(1); + expect(links[0]).toEqual( + expect.objectContaining({ + title: 'testDS', + href: '/explore?left={"datasource":"testDS","queries":["12345"]}', + onClick: undefined, + }) + ); + }); +}); + +describe('applyRawFieldOverrides', () => { + const getNumberFieldConfig = () => ({ + custom: {}, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { + color: 'green', + value: (null as unknown) as number, + }, + { + color: 'red', + value: 80, + }, + ], + }, + mappings: [], + color: { + mode: FieldColorModeId.Thresholds, + }, + min: 0, + max: 1599124316808, + }); + + const getEmptyConfig = () => ({ + custom: {}, + mappings: [], + }); + + const getDisplayValue = (frames: DataFrame[], frameIndex: number, fieldIndex: number) => { + const field = frames[frameIndex].fields[fieldIndex]; + const value = field.values.get(0); + return field.display!(value); + }; + + const expectRawDataDisplayValue = (frames: DataFrame[], frameIndex: number) => { + expect(getDisplayValue(frames, frameIndex, 0)).toEqual({ text: '1599045551050', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 1)).toEqual({ text: '3.14159265359', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 2)).toEqual({ text: '0', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 3)).toEqual({ text: '0', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 4)).toEqual({ text: 'A - string', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 5)).toEqual({ text: '1599045551050', numeric: null }); + }; + + const expectFormattedDataDisplayValue = (frames: DataFrame[], frameIndex: number) => { + expect(getDisplayValue(frames, frameIndex, 0)).toEqual({ + color: '#F2495C', + numeric: 1599045551050, + prefix: undefined, + suffix: undefined, + text: '1599045551050', + percent: expect.any(Number), + }); + + expect(getDisplayValue(frames, frameIndex, 1)).toEqual({ + color: '#73BF69', + numeric: 3.14159265359, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '3.142', + }); + + expect(getDisplayValue(frames, frameIndex, 2)).toEqual({ + color: '#73BF69', + numeric: 0, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '0', + }); + + expect(getDisplayValue(frames, frameIndex, 3)).toEqual({ + color: '#F2495C', // red + numeric: 0, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '0', + }); + + expect(getDisplayValue(frames, frameIndex, 4)).toEqual({ + color: '#73BF69', // value from classic pallet + numeric: NaN, + percent: 1, + prefix: undefined, + suffix: undefined, + text: 'A - string', + }); + + expect(getDisplayValue(frames, frameIndex, 5)).toEqual({ + color: '#808080', + numeric: 1599045551050, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '2020-09-02 11:19:11', + }); + }; + + describe('when called', () => { + it('then all fields should have their display processor replaced with the raw display processor', () => { + const numberAsEpoc: Field = { + name: 'numberAsEpoc', + type: FieldType.number, + values: new ArrayVector([1599045551050]), + config: getNumberFieldConfig(), + }; + + const numberWithDecimals: Field = { + name: 'numberWithDecimals', + type: FieldType.number, + values: new ArrayVector([3.14159265359]), + config: { + ...getNumberFieldConfig(), + decimals: 3, + }, + }; + + const numberAsBoolean: Field = { + name: 'numberAsBoolean', + type: FieldType.number, + values: new ArrayVector([0]), + config: getNumberFieldConfig(), + }; + + const boolean: Field = { + name: 'boolean', + type: FieldType.boolean, + values: new ArrayVector([0]), + config: getEmptyConfig(), + }; + + const string: Field = { + name: 'string', + type: FieldType.boolean, + values: new ArrayVector(['A - string']), + config: getEmptyConfig(), + }; + + const datetime: Field = { + name: 'datetime', + type: FieldType.time, + values: new ArrayVector([1599045551050]), + config: { + unit: 'dateTimeAsIso', + }, + }; + + const dataFrameA: DataFrame = toDataFrame({ + fields: [numberAsEpoc, numberWithDecimals, numberAsBoolean, boolean, string, datetime], + }); + + const theme = createTheme(); + + dataFrameA.fields[0].display = getDisplayProcessor({ field: dataFrameA.fields[0], theme }); + dataFrameA.fields[1].display = getDisplayProcessor({ field: dataFrameA.fields[1], theme }); + dataFrameA.fields[2].display = getDisplayProcessor({ field: dataFrameA.fields[2], theme }); + dataFrameA.fields[3].display = getDisplayProcessor({ field: dataFrameA.fields[3], theme }); + dataFrameA.fields[4].display = getDisplayProcessor({ field: dataFrameA.fields[4], theme }); + dataFrameA.fields[5].display = getDisplayProcessor({ field: dataFrameA.fields[5], theme, timeZone: 'utc' }); + + const dataFrameB: DataFrame = toDataFrame({ + fields: [numberAsEpoc, numberWithDecimals, numberAsBoolean, boolean, string, datetime], + }); + + dataFrameB.fields[0].display = getDisplayProcessor({ field: dataFrameB.fields[0], theme }); + dataFrameB.fields[1].display = getDisplayProcessor({ field: dataFrameB.fields[1], theme }); + dataFrameB.fields[2].display = getDisplayProcessor({ field: dataFrameB.fields[2], theme }); + dataFrameB.fields[3].display = getDisplayProcessor({ field: dataFrameB.fields[3], theme }); + dataFrameB.fields[4].display = getDisplayProcessor({ field: dataFrameB.fields[4], theme }); + dataFrameB.fields[5].display = getDisplayProcessor({ field: dataFrameB.fields[5], theme, timeZone: 'utc' }); + + const data = [dataFrameA, dataFrameB]; + const rawData = applyRawFieldOverrides(data); + + // expect raw data is correct + expectRawDataDisplayValue(rawData, 0); + expectRawDataDisplayValue(rawData, 1); + + // expect the original data is still the same + expectFormattedDataDisplayValue(data, 0); + expectFormattedDataDisplayValue(data, 1); + }); + }); +}); diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts new file mode 100644 index 0000000..c81cc7c --- /dev/null +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -0,0 +1,450 @@ +import { + ApplyFieldOverrideOptions, + DataFrame, + DataLink, + DynamicConfigValue, + Field, + FieldColorModeId, + FieldConfig, + FieldConfigPropertyItem, + FieldOverrideContext, + FieldType, + InterpolateFunction, + LinkModel, + NumericRange, + ScopedVars, + TimeZone, + ValueLinkConfig, +} from '../types'; +import { fieldMatchers, reduceField, ReducerID } from '../transformations'; +import { FieldMatcher } from '../types/transformations'; +import { isNumber, set, unset, get } from 'lodash'; +import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; +import { guessFieldTypeForField } from '../dataframe'; +import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; +import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +import { DataLinkBuiltInVars, locationUtil } from '../utils'; +import { formattedValueToString } from '../valueFormats'; +import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; +import { getFieldDisplayName, getFrameDisplayName } from './fieldState'; +import { getTimeField } from '../dataframe/processDataFrame'; +import { mapInternalLinkToExplore } from '../utils/dataLinks'; +import { getTemplateProxyForField } from './templateProxies'; + +interface OverrideProps { + match: FieldMatcher; + properties: DynamicConfigValue[]; +} + +export function findNumericFieldMinMax(data: DataFrame[]): NumericRange { + let min: number | null = null; + let max: number | null = null; + + const reducers = [ReducerID.min, ReducerID.max]; + + for (const frame of data) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + const stats = reduceField({ field, reducers }); + const statsMin = stats[ReducerID.min]; + const statsMax = stats[ReducerID.max]; + + if (min === null || statsMin < min) { + min = statsMin; + } + + if (max === null || statsMax > max) { + max = statsMax; + } + } + } + } + + return { min, max, delta: (max ?? 0) - (min ?? 0) }; +} + +/** + * Return a copy of the DataFrame with all rules applied + */ +export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFrame[] { + if (!options.data) { + return []; + } + + const source = options.fieldConfig; + if (!source) { + return options.data; + } + + const fieldConfigRegistry = options.fieldConfigRegistry ?? standardFieldConfigEditorRegistry; + + let seriesIndex = 0; + let globalRange: NumericRange | undefined = undefined; + + // Prepare the Matchers + const override: OverrideProps[] = []; + if (source.overrides) { + for (const rule of source.overrides) { + const info = fieldMatchers.get(rule.matcher.id); + if (info) { + override.push({ + match: info.get(rule.matcher.options), + properties: rule.properties, + }); + } + } + } + + return options.data.map((frame, index) => { + // Need to define this new frame here as it's passed to the getLinkSupplier function inside the fields loop + const newFrame: DataFrame = { ...frame }; + + const scopedVars: ScopedVars = { + __series: { text: 'Series', value: { name: getFrameDisplayName(frame, index) } }, // might be missing + }; + + const fields: Field[] = frame.fields.map((field) => { + // Config is mutable within this scope + const fieldScopedVars = { ...scopedVars }; + const displayName = getFieldDisplayName(field, frame, options.data); + + fieldScopedVars['__field'] = { + text: 'Field', + value: getTemplateProxyForField(field, frame, options.data), + }; + + field.state = { + ...field.state, + scopedVars: fieldScopedVars, + displayName, + }; + + const config: FieldConfig = { ...field.config }; + const context = { + field, + data: options.data!, + dataFrameIndex: index, + replaceVariables: options.replaceVariables, + fieldConfigRegistry: fieldConfigRegistry, + }; + + // Anything in the field config that's not set by the datasource + // will be filled in by panel's field configuration + setFieldConfigDefaults(config, source.defaults, context); + // Find any matching rules and then override + for (const rule of override) { + if (rule.match(field, frame, options.data!)) { + for (const prop of rule.properties) { + // config.scopedVars is set already here + setDynamicConfigValue(config, prop, context); + } + } + } + + // Try harder to set a real value that is not 'other' + let type = field.type; + if (!type || type === FieldType.other) { + const t = guessFieldTypeForField(field); + if (t) { + type = t; + } + } + + // Some units have an implied range + if (config.unit === 'percent') { + if (!isNumber(config.min)) { + config.min = 0; + } + if (!isNumber(config.max)) { + config.max = 100; + } + } else if (config.unit === 'percentunit') { + if (!isNumber(config.min)) { + config.min = 0; + } + if (!isNumber(config.max)) { + config.max = 1; + } + } + + // Set the Min/Max value automatically + let range: NumericRange | undefined = undefined; + if (field.type === FieldType.number) { + if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) { + globalRange = findNumericFieldMinMax(options.data!); + } + const min = config.min ?? globalRange!.min; + const max = config.max ?? globalRange!.max; + range = { min, max, delta: max! - min! }; + } + + // Some color modes needs series index to assign field color so we count + // up series index here but ignore time fields + if (field.type !== FieldType.time) { + seriesIndex++; + } + + // Overwrite the configs + const newField: Field = { + ...field, + config, + type, + state: { + ...field.state, + displayName: null, + seriesIndex, + range, + }, + }; + + // and set the display processor using it + newField.display = getDisplayProcessor({ + field: newField, + theme: options.theme, + timeZone: options.timeZone, + }); + + // Attach data links supplier + newField.getLinks = getLinksSupplier( + newFrame, + newField, + fieldScopedVars, + context.replaceVariables, + options.timeZone + ); + + return newField; + }); + + newFrame.fields = fields; + return newFrame; + }); +} + +export interface FieldOverrideEnv extends FieldOverrideContext { + fieldConfigRegistry: FieldConfigOptionsRegistry; +} + +export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) { + const reg = context.fieldConfigRegistry; + const item = reg.getIfExists(value.id); + if (!item) { + return; + } + + const val = item.process(value.value, context, item.settings); + + const remove = val === undefined || val === null; + + if (remove) { + if (item.isCustom && config.custom) { + unset(config.custom, item.path); + } else { + unset(config, item.path); + } + } else { + if (item.isCustom) { + if (!config.custom) { + config.custom = {}; + } + set(config.custom, item.path, val); + } else { + set(config, item.path, val); + } + } +} + +// config -> from DS +// defaults -> from Panel config +export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfig, context: FieldOverrideEnv) { + for (const fieldConfigProperty of context.fieldConfigRegistry.list()) { + if (fieldConfigProperty.isCustom && !config.custom) { + config.custom = {}; + } + processFieldConfigValue( + fieldConfigProperty.isCustom ? config.custom : config, + fieldConfigProperty.isCustom ? defaults.custom : defaults, + fieldConfigProperty, + context + ); + } + + validateFieldConfig(config); +} + +const processFieldConfigValue = ( + destination: Record, // it's mutable + source: Record, + fieldConfigProperty: FieldConfigPropertyItem, + context: FieldOverrideEnv +) => { + const currentConfig = get(destination, fieldConfigProperty.path); + if (currentConfig === null || currentConfig === undefined) { + const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id); + if (!item) { + return; + } + + if (item && item.shouldApply(context.field!)) { + const val = item.process(get(source, item.path), context, item.settings); + if (val !== undefined && val !== null) { + set(destination, item.path, val); + } + } + } +}; + +/** + * This checks that all options on FieldConfig make sense. It mutates any value that needs + * fixed. In particular this makes sure that the first threshold value is -Infinity (not valid in JSON) + */ +export function validateFieldConfig(config: FieldConfig) { + const { thresholds } = config; + + if (!config.color) { + if (thresholds) { + config.color = { + mode: FieldColorModeId.Thresholds, + }; + } + // No Color settings + } else if (!config.color.mode) { + // Without a mode, skip color altogether + delete config.color; + } + + // Verify that max > min (swap if necessary) + if (config.hasOwnProperty('min') && config.hasOwnProperty('max') && config.min! > config.max!) { + const tmp = config.max; + config.max = config.min; + config.min = tmp; + } +} + +export const getLinksSupplier = ( + frame: DataFrame, + field: Field, + fieldScopedVars: ScopedVars, + replaceVariables: InterpolateFunction, + timeZone?: TimeZone +) => (config: ValueLinkConfig): Array> => { + if (!field.config.links || field.config.links.length === 0) { + return []; + } + const timeRangeUrl = locationUtil.getTimeRangeUrlParams(); + const { timeField } = getTimeField(frame); + + return field.config.links.map((link: DataLink) => { + const variablesQuery = locationUtil.getVariablesUrlParams(); + let dataFrameVars = {}; + let valueVars = {}; + + // We are not displaying reduction result + if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) { + const fieldsProxy = getFieldDisplayValuesProxy({ + frame, + rowIndex: config.valueRowIndex, + timeZone: timeZone, + }); + + valueVars = { + raw: field.values.get(config.valueRowIndex), + numeric: fieldsProxy[field.name].numeric, + text: fieldsProxy[field.name].text, + time: timeField ? timeField.values.get(config.valueRowIndex) : undefined, + }; + + dataFrameVars = { + __data: { + value: { + name: frame.name, + refId: frame.refId, + fields: fieldsProxy, + }, + text: 'Data', + }, + }; + } else { + if (config.calculatedValue) { + valueVars = { + raw: config.calculatedValue.numeric, + numeric: config.calculatedValue.numeric, + text: formattedValueToString(config.calculatedValue), + }; + } + } + + const variables = { + ...fieldScopedVars, + __value: { + text: 'Value', + value: valueVars, + }, + ...dataFrameVars, + [DataLinkBuiltInVars.keepTime]: { + text: timeRangeUrl, + value: timeRangeUrl, + }, + [DataLinkBuiltInVars.includeVars]: { + text: variablesQuery, + value: variablesQuery, + }, + }; + + if (link.internal) { + // For internal links at the moment only destination is Explore. + return mapInternalLinkToExplore({ + link, + internalLink: link.internal, + scopedVars: variables, + field, + range: {} as any, + replaceVariables, + }); + } else { + let href = locationUtil.assureBaseUrl(link.url.replace(/\n/g, '')); + href = replaceVariables(href, variables); + href = locationUtil.processUrl(href); + + const info: LinkModel = { + href, + title: replaceVariables(link.title || '', variables), + target: link.targetBlank ? '_blank' : undefined, + origin: field, + }; + + return info; + } + }); +}; + +/** + * Return a copy of the DataFrame with raw data + */ +export function applyRawFieldOverrides(data: DataFrame[]): DataFrame[] { + if (!data || data.length === 0) { + return []; + } + + const newData = [...data]; + const processor = getRawDisplayProcessor(); + + for (let frameIndex = 0; frameIndex < newData.length; frameIndex++) { + const newFrame = { ...newData[frameIndex] }; + const newFields = [...newFrame.fields]; + + for (let fieldIndex = 0; fieldIndex < newFields.length; fieldIndex++) { + newFields[fieldIndex] = { + ...newFields[fieldIndex], + display: processor, + }; + } + + newData[frameIndex] = { + ...newFrame, + fields: newFields, + }; + } + + return newData; +} diff --git a/packages/grafana-data/src/field/fieldState.test.ts b/packages/grafana-data/src/field/fieldState.test.ts new file mode 100644 index 0000000..656016f --- /dev/null +++ b/packages/grafana-data/src/field/fieldState.test.ts @@ -0,0 +1,208 @@ +import { DataFrame, TIME_SERIES_VALUE_FIELD_NAME, FieldType } from '../types'; +import { getFieldDisplayName, getFrameDisplayName } from './fieldState'; +import { toDataFrame } from '../dataframe'; + +interface TitleScenario { + frames: DataFrame[]; + frameIndex?: number; // assume 0 + fieldIndex?: number; // assume 0 +} + +function checkScenario(scenario: TitleScenario): string { + const frame = scenario.frames[scenario.frameIndex ?? 0]; + const field = frame.fields[scenario.fieldIndex ?? 0]; + return getFieldDisplayName(field, frame, scenario.frames); +} + +describe('getFrameDisplayName', () => { + it('Should return frame name if set', () => { + const frame = toDataFrame({ + name: 'Series A', + fields: [{ name: 'Field 1' }], + }); + expect(getFrameDisplayName(frame)).toBe('Series A'); + }); + + it('Should return field name', () => { + const frame = toDataFrame({ + fields: [{ name: 'Field 1' }], + }); + expect(getFrameDisplayName(frame)).toBe('Field 1'); + }); + + it('Should return all field names', () => { + const frame = toDataFrame({ + fields: [{ name: 'Field A' }, { name: 'Field B' }], + }); + expect(getFrameDisplayName(frame)).toBe('Field A, Field B'); + }); + + it('Should return labels if single field with labels', () => { + const frame = toDataFrame({ + fields: [{ name: 'value', labels: { server: 'A' } }], + }); + expect(getFrameDisplayName(frame)).toBe('{server="A"}'); + }); + + it('Should return field names when labels object exist but has no keys', () => { + const frame = toDataFrame({ + fields: [{ name: 'value', labels: {} }], + }); + expect(getFrameDisplayName(frame)).toBe('value'); + }); +}); + +describe('Check field state calculations (displayName and id)', () => { + it('should use field name if no frame name', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'Field 1' }], + }), + ], + }); + expect(title).toEqual('Field 1'); + }); + + it('should use only field name if only one series', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'Series A', + fields: [{ name: 'Field 1' }], + }), + ], + }); + expect(title).toEqual('Field 1'); + }); + + it('should use frame name and field name if more than one frame', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'Series A', + fields: [{ name: 'Field 1' }], + }), + toDataFrame({ + name: 'Series B', + fields: [{ name: 'Field 1' }], + }), + ], + }); + expect(title).toEqual('Series A Field 1'); + }); + + it('should add field name count to name if it exists more than once and is equal to TIME_SERIES_VALUE_FIELD_NAME', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME }, { name: TIME_SERIES_VALUE_FIELD_NAME }], + }), + ], + }); + const title2 = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME }, { name: TIME_SERIES_VALUE_FIELD_NAME }], + }), + ], + fieldIndex: 1, + }); + + expect(title).toEqual('Value 1'); + expect(title2).toEqual('Value 2'); + }); + + it('should add field name count to name if field name exists more than once', () => { + const title2 = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'A' }, { name: 'A' }], + }), + ], + fieldIndex: 1, + }); + + expect(title2).toEqual('A 2'); + }); + + it('should only use label value if only one label', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'Value', labels: { server: 'Server A' } }], + }), + ], + }); + expect(title).toEqual('Server A'); + }); + + it('should use label value only if all series have same name', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'cpu', + fields: [{ name: 'Value', labels: { server: 'Server A' } }], + }), + toDataFrame({ + name: 'cpu', + fields: [{ name: 'Value', labels: { server: 'Server A' } }], + }), + ], + }); + expect(title).toEqual('Server A'); + }); + + it('should use label name and value if more than one label', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'Value', labels: { server: 'Server A', mode: 'B' } }], + }), + ], + }); + expect(title).toEqual('{mode="B", server="Server A"}'); + }); + + it('should use field name even when it is TIME_SERIES_VALUE_FIELD_NAME if there are no labels', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME, labels: {} }], + }), + ], + }); + expect(title).toEqual('Value'); + }); + + it('should use series name when field name is TIME_SERIES_VALUE_FIELD_NAME and there are no labels ', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'Series A', + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME, labels: {} }], + }), + ], + }); + expect(title).toEqual('Series A'); + }); + + it('should reder loki frames', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time }, + { + name: 'line', + labels: { host: 'ec2-13-53-116-156.eu-north-1.compute.amazonaws.com', region: 'eu-north1' }, + }, + ], + }), + ], + fieldIndex: 1, + }); + expect(title).toEqual('line {host="ec2-13-53-116-156.eu-north-1.compute.amazonaws.com", region="eu-north1"}'); + }); +}); diff --git a/packages/grafana-data/src/field/fieldState.ts b/packages/grafana-data/src/field/fieldState.ts new file mode 100644 index 0000000..0e88d5b --- /dev/null +++ b/packages/grafana-data/src/field/fieldState.ts @@ -0,0 +1,198 @@ +import { DataFrame, Field, TIME_SERIES_VALUE_FIELD_NAME, FieldType, TIME_SERIES_TIME_FIELD_NAME } from '../types'; +import { formatLabels } from '../utils/labels'; + +/** + * Get an appropriate display title + */ +export function getFrameDisplayName(frame: DataFrame, index?: number) { + if (frame.name) { + return frame.name; + } + + // Single field with tags + const valuesWithLabels: Field[] = []; + for (const field of frame.fields) { + if (field.labels && Object.keys(field.labels).length > 0) { + valuesWithLabels.push(field); + } + } + + if (valuesWithLabels.length === 1) { + return formatLabels(valuesWithLabels[0].labels!); + } + + // list all the + if (index === undefined) { + return frame.fields + .filter((f) => f.type !== FieldType.time) + .map((f) => getFieldDisplayName(f, frame)) + .join(', '); + } + + if (frame.refId) { + return `Series (${frame.refId})`; + } + + return `Series (${index})`; +} + +export function getFieldDisplayName(field: Field, frame?: DataFrame, allFrames?: DataFrame[]): string { + const existingTitle = field.state?.displayName; + + if (existingTitle) { + return existingTitle; + } + + const displayName = calculateFieldDisplayName(field, frame, allFrames); + field.state = field.state || {}; + field.state.displayName = displayName; + + return displayName; +} + +/** + * Get an appropriate display name. If the 'displayName' field config is set, use that + */ +function calculateFieldDisplayName(field: Field, frame?: DataFrame, allFrames?: DataFrame[]): string { + const hasConfigTitle = field.config?.displayName && field.config?.displayName.length; + + let displayName = hasConfigTitle ? field.config!.displayName! : field.name; + + if (hasConfigTitle) { + return displayName; + } + + if (frame && field.config?.displayNameFromDS) { + return field.config.displayNameFromDS; + } + + // This is an ugly exception for time field + // For time series we should normally treat time field with same name + // But in case it has a join source we should handle it as normal field + if (field.type === FieldType.time && !field.labels) { + return displayName ?? TIME_SERIES_TIME_FIELD_NAME; + } + + let parts: string[] = []; + let frameNamesDiffer = false; + + if (allFrames && allFrames.length > 1) { + for (let i = 1; i < allFrames.length; i++) { + const frame = allFrames[i]; + if (frame.name !== allFrames[i - 1].name) { + frameNamesDiffer = true; + break; + } + } + } + + let frameNameAdded = false; + let labelsAdded = false; + + if (frameNamesDiffer && frame?.name) { + parts.push(frame.name); + frameNameAdded = true; + } + + if (field.name && field.name !== TIME_SERIES_VALUE_FIELD_NAME) { + parts.push(field.name); + } + + if (field.labels && frame) { + let singleLabelName = getSingleLabelName(allFrames ?? [frame]); + + if (!singleLabelName) { + let allLabels = formatLabels(field.labels); + if (allLabels) { + parts.push(allLabels); + labelsAdded = true; + } + } else if (field.labels[singleLabelName]) { + parts.push(field.labels[singleLabelName]); + labelsAdded = true; + } + } + + // if we have not added frame name and no labels, and field name = Value, we should add frame name + if (frame && !frameNameAdded && !labelsAdded && field.name === TIME_SERIES_VALUE_FIELD_NAME) { + if (frame.name && frame.name.length > 0) { + parts.push(frame.name); + frameNameAdded = true; + } + } + + if (parts.length) { + displayName = parts.join(' '); + } else if (field.name) { + displayName = field.name; + } else { + displayName = TIME_SERIES_VALUE_FIELD_NAME; + } + + // Ensure unique field name + if (displayName === field.name) { + displayName = getUniqueFieldName(field, frame); + } + + return displayName; +} + +function getUniqueFieldName(field: Field, frame?: DataFrame) { + let dupeCount = 0; + let foundSelf = false; + + if (frame) { + for (let i = 0; i < frame.fields.length; i++) { + const otherField = frame.fields[i]; + + if (field === otherField) { + foundSelf = true; + + if (dupeCount > 0) { + dupeCount++; + break; + } + } else if (field.name === otherField.name) { + dupeCount++; + + if (foundSelf) { + break; + } + } + } + } + + if (dupeCount) { + return `${field.name} ${dupeCount}`; + } + + return field.name; +} + +/** + * Checks all data frames and return name of label if there is only one label name in all frames + */ +function getSingleLabelName(frames: DataFrame[]): string | null { + let singleName: string | null = null; + + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + + for (const field of frame.fields) { + if (!field.labels) { + continue; + } + + // yes this should be in! + for (const labelKey in field.labels) { + if (singleName === null) { + singleName = labelKey; + } else if (labelKey !== singleName) { + return null; + } + } + } + } + + return singleName; +} diff --git a/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx new file mode 100644 index 0000000..010fdaf --- /dev/null +++ b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx @@ -0,0 +1,71 @@ +import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; +import { applyFieldOverrides } from './fieldOverrides'; +import { toDataFrame } from '../dataframe'; +import { createTheme } from '../themes'; + +describe('getFieldDisplayValuesProxy', () => { + const data = applyFieldOverrides({ + data: [ + toDataFrame({ + fields: [ + { name: 'Time', values: [1, 2, 3] }, + { + name: 'power', + values: [100, 200, 300], + labels: { + name: 'POWAH!', + }, + config: { + displayName: 'The Power', + }, + }, + { + name: 'Last', + values: ['a', 'b', 'c'], + }, + ], + }), + ], + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (val: string) => val, + timeZone: 'utc', + theme: createTheme(), + })[0]; + + it('should define all display functions', () => { + // Field display should be set + for (const field of data.fields) { + expect(field.display).toBeDefined(); + } + }); + + it('should format the time values in UTC', () => { + // Test Proxies in general + const p = getFieldDisplayValuesProxy({ frame: data, rowIndex: 0 }); + const time = p.Time; + expect(time.numeric).toEqual(1); + expect(time.text).toEqual('1970-01-01 00:00:00'); + + // Should get to the same values by name or index + const time2 = p[0]; + expect(time2.toString()).toEqual(time.toString()); + }); + + it('Lookup by name, index, or displayName', () => { + const p = getFieldDisplayValuesProxy({ frame: data, rowIndex: 2 }); + expect(p.power.numeric).toEqual(300); + expect(p['power'].numeric).toEqual(300); + expect(p['POWAH!'].numeric).toEqual(300); + expect(p['The Power'].numeric).toEqual(300); + expect(p[1].numeric).toEqual(300); + }); + + it('should return undefined when missing', () => { + const p = getFieldDisplayValuesProxy({ frame: data, rowIndex: 0 }); + expect(p.xyz).toBeUndefined(); + expect(p[100]).toBeUndefined(); + }); +}); diff --git a/packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts new file mode 100644 index 0000000..255cc7b --- /dev/null +++ b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts @@ -0,0 +1,51 @@ +import { toNumber } from 'lodash'; +import { DataFrame, DisplayValue, TimeZone } from '../types'; +import { formattedValueToString } from '../valueFormats'; + +/** + * + * @param frame + * @param rowIndex + * @param options + * @internal + */ +export function getFieldDisplayValuesProxy(options: { + frame: DataFrame; + rowIndex: number; + timeZone?: TimeZone; +}): Record { + return new Proxy({} as Record, { + get: (obj: any, key: string) => { + // 1. Match the name + let field = options.frame.fields.find((f) => key === f.name); + if (!field) { + // 2. Match the array index + const k = toNumber(key); + field = options.frame.fields[k]; + } + if (!field) { + // 3. Match the config displayName + field = options.frame.fields.find((f) => key === f.config.displayName); + } + if (!field) { + // 4. Match the name label + field = options.frame.fields.find((f) => { + if (f.labels) { + return key === f.labels.name; + } + return false; + }); + } + if (!field) { + return undefined; + } + if (!field.display) { + throw new Error('Field missing display processor ' + field.name); + } + const raw = field.values.get(options.rowIndex); + const disp = field.display(raw); + disp.toString = () => formattedValueToString(disp); + return disp; + }, + }); +} diff --git a/packages/grafana-data/src/field/index.ts b/packages/grafana-data/src/field/index.ts new file mode 100644 index 0000000..72cb12d --- /dev/null +++ b/packages/grafana-data/src/field/index.ts @@ -0,0 +1,18 @@ +export * from './fieldDisplay'; +export * from './displayProcessor'; +export * from './standardFieldConfigEditorRegistry'; +export * from './overrides/processors'; + +export { + getFieldColorModeForField, + getFieldColorMode, + fieldColorModeRegistry, + FieldColorMode, + getFieldSeriesColor, +} from './fieldColor'; +export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +export { sortThresholds, getActiveThreshold } from './thresholds'; +export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides } from './fieldOverrides'; +export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; +export { getFieldDisplayName, getFrameDisplayName } from './fieldState'; +export { getScaleCalculator, getFieldConfigWithMinMax } from './scale'; diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts new file mode 100644 index 0000000..0a36bfc --- /dev/null +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -0,0 +1,150 @@ +import { DataLink, FieldOverrideContext, SelectableValue, ThresholdsConfig, ValueMapping } from '../../types'; + +export const identityOverrideProcessor = (value: T, _context: FieldOverrideContext, _settings: any) => { + return value; +}; + +export interface NumberFieldConfigSettings { + placeholder?: string; + integer?: boolean; + min?: number; + max?: number; + step?: number; +} + +export const numberOverrideProcessor = ( + value: any, + context: FieldOverrideContext, + settings?: NumberFieldConfigSettings +) => { + if (value === undefined || value === null) { + return undefined; + } + + return parseFloat(value); +}; + +export interface SliderFieldConfigSettings { + min: number; + max: number; + step?: number; +} + +export interface DataLinksFieldConfigSettings {} + +export const dataLinksOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: DataLinksFieldConfigSettings +) => { + return value as DataLink[]; +}; + +export interface ValueMappingFieldConfigSettings {} + +export const valueMappingsOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: ValueMappingFieldConfigSettings +) => { + return value as ValueMapping[]; // !!!! likely not !!!! +}; + +export interface SelectFieldConfigSettings { + allowCustomValue?: boolean; + + /** The default options */ + options: Array>; + + /** Optionally use the context to define the options */ + getOptions?: (context: FieldOverrideContext) => Promise>>; +} + +export const selectOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: SelectFieldConfigSettings +) => { + return value; +}; + +export interface StringFieldConfigSettings { + placeholder?: string; + maxLength?: number; + expandTemplateVars?: boolean; + useTextarea?: boolean; + rows?: number; +} + +export const stringOverrideProcessor = ( + value: any, + context: FieldOverrideContext, + settings?: StringFieldConfigSettings +) => { + if (value === null || value === undefined) { + return value; + } + if (settings && settings.expandTemplateVars && context.replaceVariables) { + return context.replaceVariables(value, context.field!.state!.scopedVars); + } + return `${value}`; +}; + +export interface ThresholdsFieldConfigSettings { + // Anything? +} + +export const thresholdsOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: ThresholdsFieldConfigSettings +) => { + return value as ThresholdsConfig; // !!!! likely not !!!! +}; + +export interface UnitFieldConfigSettings {} + +export const unitOverrideProcessor = ( + value: boolean, + _context: FieldOverrideContext, + _settings?: UnitFieldConfigSettings +) => { + return value; +}; + +export const booleanOverrideProcessor = ( + value: boolean, + _context: FieldOverrideContext, + _settings?: ThresholdsFieldConfigSettings +) => { + return value; // !!!! likely not !!!! +}; + +export interface FieldColorConfigSettings { + /** + * When switching to a visualization that does not support by value coloring then Grafana will + * switch to a by series palette based color mode + */ + byValueSupport?: boolean; + /** + * When switching to a visualization that has this set to true then Grafana will change color mode + * to from thresholds if it was set to a by series palette + */ + preferThresholdsMode?: boolean; + /** + * Set to true if the visualization supports both by value and by series + * This will enable the Color by series UI option that sets the `color.seriesBy` option. + */ + bySeriesSupport?: boolean; +} + +export interface StatsPickerConfigSettings { + /** + * Enable multi-selection in the stats picker + */ + allowMultiple: boolean; + /** + * Default stats to be use in the stats picker + */ + defaultStat?: string; +} diff --git a/packages/grafana-data/src/field/scale.test.ts b/packages/grafana-data/src/field/scale.test.ts new file mode 100644 index 0000000..45cd98a --- /dev/null +++ b/packages/grafana-data/src/field/scale.test.ts @@ -0,0 +1,76 @@ +import { ThresholdsMode, Field, FieldType } from '../types'; +import { sortThresholds } from './thresholds'; +import { ArrayVector } from '../vector/ArrayVector'; +import { ensureGlobalRangeOnState, getScaleCalculator } from './scale'; +import { createTheme } from '../themes'; +import { getColorForTheme } from '../utils'; +import { toDataFrame } from '../dataframe'; + +describe('getScaleCalculator', () => { + it('should return percent, threshold and color', () => { + const thresholds = [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]; + + const field: Field = { + name: 'test', + config: { thresholds: { mode: ThresholdsMode.Absolute, steps: sortThresholds(thresholds) } }, + type: FieldType.number, + values: new ArrayVector([0, 50, 100]), + }; + + const calc = getScaleCalculator(field, createTheme()); + expect(calc(70)).toEqual({ + percent: 0.7, + threshold: thresholds[1], + color: '#EAB839', + }); + }); + + it('reasonable boolean values', () => { + const field: Field = { + name: 'test', + config: {}, + type: FieldType.boolean, + values: new ArrayVector([true, false, true]), + }; + + const theme = createTheme(); + const calc = getScaleCalculator(field, theme); + expect(calc(true as any)).toEqual({ + percent: 1, + color: getColorForTheme('green', theme.v1), + threshold: undefined, + }); + expect(calc(false as any)).toEqual({ + percent: 0, + color: getColorForTheme('red', theme.v1), + threshold: undefined, + }); + }); +}); + +describe('ensure global scales', () => { + it('should fill in all numeric values', () => { + const frame = toDataFrame({ + fields: [ + { type: FieldType.number, values: [1, 2, 3] }, + { type: FieldType.number, values: [7, 8, 9] }, + { type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + ensureGlobalRangeOnState([frame]); + + expect(frame.fields[0].state!.range).toMatchInlineSnapshot(` + Object { + "delta": 8, + "max": 9, + "min": 1, + } + `); + + expect(frame.fields[2].state?.range).toBeUndefined(); + }); +}); diff --git a/packages/grafana-data/src/field/scale.ts b/packages/grafana-data/src/field/scale.ts new file mode 100644 index 0000000..cf94162 --- /dev/null +++ b/packages/grafana-data/src/field/scale.ts @@ -0,0 +1,153 @@ +import { isNumber } from 'lodash'; +import { GrafanaTheme2 } from '../themes/types'; +import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { DataFrame, Field, FieldConfig, FieldType, NumericRange, Threshold } from '../types'; +import { getFieldColorModeForField } from './fieldColor'; +import { findNumericFieldMinMax } from './fieldOverrides'; +import { getActiveThresholdForValue } from './thresholds'; + +export interface ColorScaleValue { + percent: number; // 0-1 + threshold: Threshold; + color: string; +} + +export type ScaleCalculator = (value: number) => ColorScaleValue; + +export function getScaleCalculator(field: Field, theme: GrafanaTheme2): ScaleCalculator { + if (field.type === FieldType.boolean) { + return getBooleanScaleCalculator(field, theme); + } + + const mode = getFieldColorModeForField(field); + const getColor = mode.getCalculator(field, theme); + const info = field.state?.range ?? getMinMaxAndDelta(field); + + return (value: number) => { + let percent = 0; + + if (value !== -Infinity) { + percent = (value - info.min!) / info.delta; + } + + const threshold = getActiveThresholdForValue(field, value, percent); + + return { + percent, + threshold, + color: getColor(value, percent, threshold), + }; + }; +} + +function getBooleanScaleCalculator(field: Field, theme: GrafanaTheme2): ScaleCalculator { + const trueValue: ColorScaleValue = { + color: theme.visualization.getColorByName('green'), + percent: 1, + threshold: (undefined as unknown) as Threshold, + }; + + const falseValue: ColorScaleValue = { + color: theme.visualization.getColorByName('red'), + percent: 0, + threshold: (undefined as unknown) as Threshold, + }; + + const mode = getFieldColorModeForField(field); + if (mode.isContinuous && mode.getColors) { + const colors = mode.getColors(theme); + trueValue.color = colors[colors.length - 1]; + falseValue.color = colors[0]; + } + + return (value: number) => { + return Boolean(value) ? trueValue : falseValue; + }; +} + +function getMinMaxAndDelta(field: Field): NumericRange { + if (field.type !== FieldType.number) { + return { min: 0, max: 100, delta: 100 }; + } + + // Calculate min/max if required + let min = field.config.min; + let max = field.config.max; + + if (!isNumber(min) || !isNumber(max)) { + if (field.values && field.values.length) { + const stats = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] }); + if (!isNumber(min)) { + min = stats[ReducerID.min]; + } + if (!isNumber(max)) { + max = stats[ReducerID.max]; + } + } else { + min = 0; + max = 100; + } + } + + return { + min, + max, + delta: max! - min!, + }; +} + +/** + * @internal + */ +export function getFieldConfigWithMinMax(field: Field, local?: boolean): FieldConfig { + const { config } = field; + let { min, max } = config; + + if (isNumber(min) && isNumber(max)) { + return config; + } + + if (local || !field.state?.range) { + return { ...config, ...getMinMaxAndDelta(field) }; + } + + return { ...config, ...field.state.range }; +} + +/** + * This will check that each field has a range value stored on state + * If the value is missing, the global range will be calculated and + * saved in the field state. + * + * The same process usually happens in `applyFieldOverrieds`, but + * when the process can be skipped the global range may be missing + * + * @internal + */ +export function ensureGlobalRangeOnState(frames?: DataFrame[]) { + if (!frames) { + return; + } + + let globalRange: NumericRange | undefined = undefined; + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + if (field.state?.range) { + continue; // already set + } + const { config } = field; + if (!globalRange && (config.min == null || config.max == null)) { + globalRange = findNumericFieldMinMax(frames); + } + + const min = config.min ?? globalRange!.min; + const max = config.max ?? globalRange!.max; + if (!field.state) { + field.state = {}; + } + field.state.range = { min, max, delta: max! - min! }; + } + } + } +} diff --git a/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts b/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts new file mode 100644 index 0000000..31a5c24 --- /dev/null +++ b/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts @@ -0,0 +1,28 @@ +import { Registry, RegistryItem } from '../utils/Registry'; +import { ComponentType } from 'react'; +import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +import { DataFrame, InterpolateFunction, VariableSuggestionsScope, VariableSuggestion } from '../types'; +import { EventBus } from '../events'; + +export interface StandardEditorContext { + data: DataFrame[]; // All results + replaceVariables?: InterpolateFunction; + eventBus?: EventBus; + getSuggestions?: (scope?: VariableSuggestionsScope) => VariableSuggestion[]; + options?: TOptions; + isOverride?: boolean; +} + +export interface StandardEditorProps { + value: TValue; + onChange: (value?: TValue) => void; + item: StandardEditorsRegistryItem; + context: StandardEditorContext; +} +export interface StandardEditorsRegistryItem extends RegistryItem { + editor: ComponentType>; + settings?: TSettings; +} +export const standardFieldConfigEditorRegistry = new FieldConfigOptionsRegistry(); + +export const standardEditorsRegistry = new Registry>(); diff --git a/packages/grafana-data/src/field/templateProxies.test.ts b/packages/grafana-data/src/field/templateProxies.test.ts new file mode 100644 index 0000000..d1ea674 --- /dev/null +++ b/packages/grafana-data/src/field/templateProxies.test.ts @@ -0,0 +1,32 @@ +import { getTemplateProxyForField } from './templateProxies'; +import { toDataFrame } from '../dataframe'; + +describe('Template proxies', () => { + it('supports name and displayName', () => { + const frames = [ + toDataFrame({ + fields: [ + { + name: '🔥', + config: { displayName: '✨' }, + labels: { + b: 'BBB', + a: 'AAA', + }, + }, + ], + }), + ]; + + const f = getTemplateProxyForField(frames[0].fields[0], frames[0], frames); + + expect(f.name).toEqual('🔥'); + expect(f.displayName).toEqual('✨'); + expect(`${f.labels}`).toEqual('a="AAA", b="BBB"'); + expect(f.labels.__values).toEqual('AAA, BBB'); + expect(f.labels.a).toEqual('AAA'); + + // Deprecated syntax + expect(`${f.formattedLabels}`).toEqual('a="AAA", b="BBB"'); + }); +}); diff --git a/packages/grafana-data/src/field/templateProxies.ts b/packages/grafana-data/src/field/templateProxies.ts new file mode 100644 index 0000000..c667ed8 --- /dev/null +++ b/packages/grafana-data/src/field/templateProxies.ts @@ -0,0 +1,39 @@ +import { DataFrame, Field } from '../types'; +import { getFieldDisplayName } from './fieldState'; +import { formatLabels } from '../utils/labels'; + +/** + * This object is created often, and only used when tmplates exist. Using a proxy lets us delay + * calculations of the more complex structures (label names) until they are actually used + */ +export function getTemplateProxyForField(field: Field, frame?: DataFrame, frames?: DataFrame[]): any { + return new Proxy( + {} as any, // This object shows up in test snapshots + { + get: (obj: Field, key: string, reciever: any) => { + if (key === 'name') { + return field.name; + } + + if (key === 'displayName') { + return getFieldDisplayName(field, frame, frames); + } + + if (key === 'labels' || key === 'formattedLabels') { + // formattedLabels deprecated + if (!field.labels) { + return ''; + } + return { + ...field.labels, + __values: Object.values(field.labels).sort().join(', '), + toString: () => { + return formatLabels(field.labels!, '', true); + }, + }; + } + return undefined; // (field as any)[key]; // any property? + }, + } + ); +} diff --git a/packages/grafana-data/src/field/thresholds.test.ts b/packages/grafana-data/src/field/thresholds.test.ts new file mode 100644 index 0000000..52b232b --- /dev/null +++ b/packages/grafana-data/src/field/thresholds.test.ts @@ -0,0 +1,93 @@ +import { ThresholdsConfig, ThresholdsMode, FieldConfig, Threshold, Field, FieldType } from '../types'; +import { sortThresholds, getActiveThreshold, getActiveThresholdForValue } from './thresholds'; +import { validateFieldConfig } from './fieldOverrides'; +import { ArrayVector } from '../vector/ArrayVector'; + +describe('thresholds', () => { + test('sort thresholds', () => { + const thresholds: ThresholdsConfig = { + steps: [ + { color: 'TEN', value: 10 }, + { color: 'HHH', value: 100 }, + { color: 'ONE', value: 1 }, + ], + mode: ThresholdsMode.Absolute, + }; + const sorted = sortThresholds(thresholds.steps).map((t) => t.value); + expect(sorted).toEqual([1, 10, 100]); + const config: FieldConfig = { thresholds }; + + // Mutates and sorts the + validateFieldConfig(config); + expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); + }); + + test('find active', () => { + const thresholds: ThresholdsConfig = { + steps: [ + { color: 'ONE', value: 1 }, + { color: 'TEN', value: 10 }, + { color: 'HHH', value: 100 }, + ], + mode: ThresholdsMode.Absolute, + }; + const config: FieldConfig = { thresholds }; + // Mutates and sets ONE to -Infinity + validateFieldConfig(config); + expect(getActiveThreshold(-1, thresholds.steps).color).toEqual('ONE'); + expect(getActiveThreshold(1, thresholds.steps).color).toEqual('ONE'); + expect(getActiveThreshold(5, thresholds.steps).color).toEqual('ONE'); + expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); + expect(getActiveThreshold(11, thresholds.steps).color).toEqual('TEN'); + expect(getActiveThreshold(99, thresholds.steps).color).toEqual('TEN'); + expect(getActiveThreshold(100, thresholds.steps).color).toEqual('HHH'); + expect(getActiveThreshold(1000, thresholds.steps).color).toEqual('HHH'); + }); + + function getThreshold(value: number, steps: Threshold[], mode: ThresholdsMode, percent = 1): Threshold { + const field: Field = { + name: 'test', + config: { thresholds: { mode: mode, steps: sortThresholds(steps) } }, + type: FieldType.number, + values: new ArrayVector([]), + }; + validateFieldConfig(field.config!); + return getActiveThresholdForValue(field, value, percent); + } + + describe('Get color from threshold', () => { + it('should get first threshold color when only one threshold', () => { + const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }]; + expect(getThreshold(49, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[0]); + }); + + it('should get the threshold color if value is same as a threshold', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + expect(getThreshold(50, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[1]); + }); + + it('should get the nearest threshold color between thresholds', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + expect(getThreshold(55, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[1]); + }); + + it('should be able to get percent based threshold', () => { + const thresholds = [ + { index: 0, value: 0, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.9)).toEqual(thresholds[2]); + expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.5)).toEqual(thresholds[1]); + expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.2)).toEqual(thresholds[0]); + }); + }); +}); diff --git a/packages/grafana-data/src/field/thresholds.ts b/packages/grafana-data/src/field/thresholds.ts new file mode 100644 index 0000000..8328483 --- /dev/null +++ b/packages/grafana-data/src/field/thresholds.ts @@ -0,0 +1,38 @@ +import { Threshold, FALLBACK_COLOR, Field, ThresholdsMode } from '../types'; + +export const fallBackTreshold: Threshold = { value: 0, color: FALLBACK_COLOR }; + +export function getActiveThreshold(value: number, thresholds: Threshold[] | undefined): Threshold { + if (!thresholds || thresholds.length === 0) { + return fallBackTreshold; + } + + let active = thresholds[0]; + + for (const threshold of thresholds) { + if (value >= threshold.value) { + active = threshold; + } else { + break; + } + } + + return active; +} + +export function getActiveThresholdForValue(field: Field, value: number, percent: number): Threshold { + const { thresholds } = field.config; + + if (thresholds?.mode === ThresholdsMode.Percentage) { + return getActiveThreshold(percent * 100, thresholds?.steps); + } + + return getActiveThreshold(value, thresholds?.steps); +} + +/** + * Sorts the thresholds + */ +export function sortThresholds(thresholds: Threshold[]) { + return thresholds.sort((t1, t2) => t1.value - t2.value); +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts new file mode 100644 index 0000000..8069404 --- /dev/null +++ b/packages/grafana-data/src/index.ts @@ -0,0 +1,26 @@ +/** + * A library containing most of the core functionality and data types used in Grafana. + * + * @packageDocumentation + */ +export * from './utils'; +export * from './types'; +export * from './vector'; +export * from './dataframe'; +export * from './transformations'; +export * from './datetime'; +export * from './text'; +export * from './valueFormats'; +export * from './field'; +export * from './events'; +export * from './themes'; +export * from './monaco'; +export { + ValueMatcherOptions, + BasicValueMatcherOptions, + RangeValueMatcherOptions, +} from './transformations/matchers/valueMatchers/types'; +export { LayoutModes, LayoutMode } from './types/layout'; +export { PanelPlugin, SetFieldConfigOptionsArgs, StandardOptionConfig } from './panel/PanelPlugin'; +export { createFieldConfigRegistry } from './panel/registryFactories'; +export { QueryRunner, QueryRunnerOptions } from './types/queryRunner'; diff --git a/packages/grafana-data/src/monaco/index.ts b/packages/grafana-data/src/monaco/index.ts new file mode 100644 index 0000000..4aed44e --- /dev/null +++ b/packages/grafana-data/src/monaco/index.ts @@ -0,0 +1 @@ +export * from './languageRegistry'; diff --git a/packages/grafana-data/src/monaco/languageRegistry.ts b/packages/grafana-data/src/monaco/languageRegistry.ts new file mode 100644 index 0000000..fcfc843 --- /dev/null +++ b/packages/grafana-data/src/monaco/languageRegistry.ts @@ -0,0 +1,13 @@ +import { Registry, RegistryItem } from '../utils/Registry'; + +/** + * @alpha + */ +export interface MonacoLanguageRegistryItem extends RegistryItem { + init: () => Promise; +} + +/** + * @alpha + */ +export const monacoLanguageRegistry = new Registry(); diff --git a/packages/grafana-data/src/panel/PanelPlugin.test.tsx b/packages/grafana-data/src/panel/PanelPlugin.test.tsx new file mode 100644 index 0000000..2e238e6 --- /dev/null +++ b/packages/grafana-data/src/panel/PanelPlugin.test.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import { identityOverrideProcessor, standardEditorsRegistry, standardFieldConfigEditorRegistry } from '../field'; +import { PanelPlugin } from './PanelPlugin'; +import { FieldConfigProperty } from '../types'; + +describe('PanelPlugin', () => { + describe('declarative options', () => { + beforeAll(() => { + standardFieldConfigEditorRegistry.setInit(() => { + return [ + { + id: FieldConfigProperty.Min, + path: 'min', + }, + { + id: FieldConfigProperty.Max, + path: 'max', + }, + ] as any; + }); + standardEditorsRegistry.setInit(() => { + return [ + { + id: 'number', + }, + ] as any; + }); + }); + + test('field config UI API', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addCustomEditor({ + id: 'custom', + path: 'custom', + name: 'Custom', + description: 'Custom field config property description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + // eslint-disable-next-line react/display-name + override: () =>
Editor
, + process: identityOverrideProcessor, + settings: {}, + shouldApply: () => true, + }); + }, + }); + + expect(panel.fieldConfigRegistry.list()).toHaveLength(3); + }); + + test('options UI API', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.setPanelOptions((builder) => { + builder.addCustomEditor({ + id: 'option', + path: 'option', + name: 'Option editor', + description: 'Option editor description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + settings: {}, + }); + }); + + expect(panel.optionEditors).toBeDefined(); + expect(panel.optionEditors!.list()).toHaveLength(1); + }); + }); + + describe('default options', () => { + describe('panel options', () => { + test('default values', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.setPanelOptions((builder) => { + builder + .addNumberInput({ + path: 'numericOption', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }) + .addNumberInput({ + path: 'numericOptionNoDefault', + name: 'Option editor', + description: 'Option editor description', + }) + .addCustomEditor({ + id: 'customOption', + path: 'customOption', + name: 'Option editor', + description: 'Option editor description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + settings: {}, + defaultValue: { value: 'Custom default value' }, + }); + }); + + const expectedDefaults = { + numericOption: 10, + customOption: { value: 'Custom default value' }, + }; + + expect(panel.defaults).toEqual(expectedDefaults); + }); + + test('default values for nested paths', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.setPanelOptions((builder) => { + builder.addNumberInput({ + path: 'numericOption.nested', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }); + }); + + const expectedDefaults = { + numericOption: { nested: 10 }, + }; + + expect(panel.defaults).toEqual(expectedDefaults); + }); + }); + + describe('field config options', () => { + test('default values', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + useCustomConfig: (builder) => { + builder + .addNumberInput({ + path: 'numericOption', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }) + .addNumberInput({ + path: 'numericOptionNoDefault', + name: 'Option editor', + description: 'Option editor description', + }) + .addCustomEditor({ + id: 'customOption', + path: 'customOption', + name: 'Option editor', + description: 'Option editor description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + // eslint-disable-next-line react/display-name + override: () =>
Override editor
, + process: identityOverrideProcessor, + shouldApply: () => true, + settings: {}, + defaultValue: { value: 'Custom default value' }, + }); + }, + }); + + const expectedDefaults = { + numericOption: 10, + customOption: { value: 'Custom default value' }, + }; + + expect(panel.fieldConfigDefaults.defaults.custom).toEqual(expectedDefaults); + }); + + test('default values for nested paths', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addNumberInput({ + path: 'numericOption.nested', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }); + }, + }); + + const expectedDefaults = { + numericOption: { nested: 10 }, + }; + + expect(panel.fieldConfigDefaults.defaults.custom).toEqual(expectedDefaults); + }); + }); + + describe('standard field config options', () => { + test('standard config', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig(); + expect(panel.fieldConfigRegistry.list()).toHaveLength(2); + }); + + test('disabling standard config properties', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + disableStandardOptions: [FieldConfigProperty.Min], + }); + expect(panel.fieldConfigRegistry.list()).toHaveLength(1); + }); + + describe('default values', () => { + test('setting default values', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Max]: { defaultValue: 20 }, + [FieldConfigProperty.Min]: { defaultValue: 10 }, + }, + }); + + expect(panel.fieldConfigRegistry.list()).toHaveLength(2); + + expect(panel.fieldConfigDefaults).toEqual({ + defaults: { + min: 10, + max: 20, + custom: {}, + }, + overrides: [], + }); + }); + + it('should disable properties independently from the default values settings', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Max]: { defaultValue: 20 }, + }, + disableStandardOptions: [FieldConfigProperty.Min], + }); + + expect(panel.fieldConfigRegistry.list()).toHaveLength(1); + + expect(panel.fieldConfigDefaults).toEqual({ + defaults: { + max: 20, + custom: {}, + }, + overrides: [], + }); + }); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts new file mode 100644 index 0000000..c04898b --- /dev/null +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -0,0 +1,360 @@ +import { + FieldConfigSource, + GrafanaPlugin, + PanelEditorProps, + PanelMigrationHandler, + PanelOptionEditorsRegistry, + PanelPluginMeta, + PanelProps, + PanelTypeChangedHandler, + FieldConfigProperty, + PanelPluginDataSupport, +} from '../types'; +import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; +import { ComponentClass, ComponentType } from 'react'; +import { set } from 'lodash'; +import { deprecationWarning } from '../utils'; +import { FieldConfigOptionsRegistry } from '../field'; +import { createFieldConfigRegistry } from './registryFactories'; + +/** @beta */ +export type StandardOptionConfig = { + defaultValue?: any; + settings?: any; +}; + +/** @beta */ +export interface SetFieldConfigOptionsArgs { + /** + * Configuration object of the standard field config properites + * + * @example + * ```typescript + * { + * standardOptions: { + * [FieldConfigProperty.Decimals]: { + * defaultValue: 3 + * } + * } + * } + * ``` + */ + standardOptions?: Partial>; + + /** + * Array of standard field config properties that should not be available in the panel + * @example + * ```typescript + * { + * disableStandardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max, FieldConfigProperty.Unit] + * } + * ``` + */ + disableStandardOptions?: FieldConfigProperty[]; + + /** + * Function that allows custom field config properties definition. + * + * @param builder + * + * @example + * ```typescript + * useCustomConfig: builder => { + * builder + * .addNumberInput({ + * id: 'shapeBorderWidth', + * name: 'Border width', + * description: 'Border width of the shape', + * settings: { + * min: 1, + * max: 5, + * }, + * }) + * .addSelect({ + * id: 'displayMode', + * name: 'Display mode', + * description: 'How the shape shout be rendered' + * settings: { + * options: [{value: 'fill', label: 'Fill' }, {value: 'transparent', label: 'Transparent }] + * }, + * }) + * } + * ``` + */ + useCustomConfig?: (builder: FieldConfigEditorBuilder) => void; +} + +export class PanelPlugin< + TOptions = any, + TFieldConfigOptions extends object = any +> extends GrafanaPlugin { + private _defaults?: TOptions; + private _fieldConfigDefaults: FieldConfigSource = { + defaults: {}, + overrides: [], + }; + + private _fieldConfigRegistry?: FieldConfigOptionsRegistry; + private _initConfigRegistry = () => { + return new FieldConfigOptionsRegistry(); + }; + + private _optionEditors?: PanelOptionEditorsRegistry; + private registerOptionEditors?: (builder: PanelOptionsEditorBuilder) => void; + + panel: ComponentType> | null; + editor?: ComponentClass>; + onPanelMigration?: PanelMigrationHandler; + onPanelTypeChanged?: PanelTypeChangedHandler; + noPadding?: boolean; + dataSupport: PanelPluginDataSupport = { + annotations: false, + alertStates: false, + }; + + /** + * Legacy angular ctrl. If this exists it will be used instead of the panel + */ + angularPanelCtrl?: any; + + constructor(panel: ComponentType> | null) { + super(); + this.panel = panel; + } + + get defaults() { + let result = this._defaults || {}; + + if (!this._defaults) { + const editors = this.optionEditors; + + if (!editors || editors.list().length === 0) { + return null; + } + + for (const editor of editors.list()) { + set(result, editor.id, editor.defaultValue); + } + } + + return result; + } + + get fieldConfigDefaults(): FieldConfigSource { + const configDefaults = this._fieldConfigDefaults.defaults; + configDefaults.custom = {} as TFieldConfigOptions; + + for (const option of this.fieldConfigRegistry.list()) { + if (option.defaultValue === undefined) { + continue; + } + + set(configDefaults, option.id, option.defaultValue); + } + + return { + defaults: { + ...configDefaults, + }, + overrides: this._fieldConfigDefaults.overrides, + }; + } + + /** + * @deprecated setDefaults is deprecated in favor of setPanelOptions + */ + setDefaults(defaults: TOptions) { + deprecationWarning('PanelPlugin', 'setDefaults', 'setPanelOptions'); + this._defaults = defaults; + return this; + } + + get fieldConfigRegistry() { + if (!this._fieldConfigRegistry) { + this._fieldConfigRegistry = this._initConfigRegistry(); + } + + return this._fieldConfigRegistry; + } + + get optionEditors(): PanelOptionEditorsRegistry { + if (!this._optionEditors) { + const builder = new PanelOptionsEditorBuilder(); + this._optionEditors = builder.getRegistry(); + + if (this.registerOptionEditors) { + this.registerOptionEditors(builder); + } + } + + return this._optionEditors; + } + + /** + * @deprecated setEditor is deprecated in favor of setPanelOptions + */ + setEditor(editor: ComponentClass>) { + deprecationWarning('PanelPlugin', 'setEditor', 'setPanelOptions'); + this.editor = editor; + return this; + } + + setNoPadding() { + this.noPadding = true; + return this; + } + + /** + * This function is called before the panel first loads if + * the current version is different than the version that was saved. + * + * This is a good place to support any changes to the options model + */ + setMigrationHandler(handler: PanelMigrationHandler) { + this.onPanelMigration = handler; + return this; + } + + /** + * This function is called when the visualization was changed. This + * passes in the panel model for previous visualisation options inspection + * and panel model updates. + * + * This is useful for supporting PanelModel API updates when changing + * between Angular and React panels. + */ + setPanelChangeHandler(handler: PanelTypeChangedHandler) { + this.onPanelTypeChanged = handler; + return this; + } + + /** + * Enables panel options editor creation + * + * @example + * ```typescript + * + * import { ShapePanel } from './ShapePanel'; + * + * interface ShapePanelOptions {} + * + * export const plugin = new PanelPlugin(ShapePanel) + * .setPanelOptions(builder => { + * builder + * .addSelect({ + * id: 'shape', + * name: 'Shape', + * description: 'Select shape to render' + * settings: { + * options: [ + * {value: 'circle', label: 'Circle' }, + * {value: 'square', label: 'Square }, + * {value: 'triangle', label: 'Triangle } + * ] + * }, + * }) + * }) + * ``` + * + * @public + **/ + setPanelOptions(builder: (builder: PanelOptionsEditorBuilder) => void) { + // builder is applied lazily when options UI is created + this.registerOptionEditors = builder; + return this; + } + + /** + * Tells Grafana if the plugin should subscribe to annotation and alertState results. + * + * @example + * ```typescript + * + * import { ShapePanel } from './ShapePanel'; + * + * interface ShapePanelOptions {} + * + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({}) + * ... + * ... + * .setDataSupport({ + * annotations: true, + * alertStates: true, + * }); + * ``` + * + * @public + **/ + setDataSupport(support: Partial) { + this.dataSupport = { ...this.dataSupport, ...support }; + return this; + } + + /** + * Allows specifying which standard field config options panel should use and defining default values + * + * @example + * ```typescript + * + * import { ShapePanel } from './ShapePanel'; + * + * interface ShapePanelOptions {} + * + * // when plugin should use all standard options + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig(); + * + * // when plugin should only display specific standard options + * // note, that options will be displayed in the order they are provided + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({ + * standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max] + * }); + * + * // when standard option's default value needs to be provided + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({ + * standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max], + * standardOptionsDefaults: { + * [FieldConfigProperty.Min]: 20, + * [FieldConfigProperty.Max]: 100 + * } + * }); + * + * // when custom field config options needs to be provided + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({ + * useCustomConfig: builder => { + * builder + * .addNumberInput({ + * id: 'shapeBorderWidth', + * name: 'Border width', + * description: 'Border width of the shape', + * settings: { + * min: 1, + * max: 5, + * }, + * }) + * .addSelect({ + * id: 'displayMode', + * name: 'Display mode', + * description: 'How the shape shout be rendered' + * settings: { + * options: [{value: 'fill', label: 'Fill' }, {value: 'transparent', label: 'Transparent }] + * }, + * }) + * }, + * }); + * + * ``` + * + * @public + */ + useFieldConfig(config: SetFieldConfigOptionsArgs = {}) { + // builder is applied lazily when custom field configs are accessed + this._initConfigRegistry = () => createFieldConfigRegistry(config, this.meta.name); + + return this; + } +} diff --git a/packages/grafana-data/src/panel/registryFactories.ts b/packages/grafana-data/src/panel/registryFactories.ts new file mode 100644 index 0000000..694d601 --- /dev/null +++ b/packages/grafana-data/src/panel/registryFactories.ts @@ -0,0 +1,64 @@ +import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry'; +import { standardFieldConfigEditorRegistry } from '../field/standardFieldConfigEditorRegistry'; +import { FieldConfigProperty } from '../types/fieldOverrides'; +import { FieldConfigEditorBuilder } from '../utils/OptionsUIBuilders'; +import { SetFieldConfigOptionsArgs } from './PanelPlugin'; + +/** + * Helper functionality to create a field config registry. + * + * @param config - configuration to base the registry on. + * @param pluginName - name of the plugin that will use the registry. + * @internal + */ +export function createFieldConfigRegistry( + config: SetFieldConfigOptionsArgs = {}, + pluginName: string +): FieldConfigOptionsRegistry { + const registry = new FieldConfigOptionsRegistry(); + + // Add custom options + if (config.useCustomConfig) { + const builder = new FieldConfigEditorBuilder(); + config.useCustomConfig(builder); + + for (const customProp of builder.getRegistry().list()) { + customProp.isCustom = true; + // need to do something to make the custom items not conflict with standard ones + // problem is id (registry index) is used as property path + // so sort of need a property path on the FieldPropertyEditorItem + customProp.id = 'custom.' + customProp.id; + registry.register(customProp); + } + } + + for (let fieldConfigProp of standardFieldConfigEditorRegistry.list()) { + if (config.disableStandardOptions) { + const isDisabled = config.disableStandardOptions.indexOf(fieldConfigProp.id as FieldConfigProperty) > -1; + if (isDisabled) { + continue; + } + } + if (config.standardOptions) { + const customDefault: any = config.standardOptions[fieldConfigProp.id as FieldConfigProperty]?.defaultValue; + const customSettings: any = config.standardOptions[fieldConfigProp.id as FieldConfigProperty]?.settings; + if (customDefault) { + fieldConfigProp = { + ...fieldConfigProp, + defaultValue: customDefault, + }; + } + + if (customSettings) { + fieldConfigProp = { + ...fieldConfigProp, + settings: fieldConfigProp.settings ? { ...fieldConfigProp.settings, ...customSettings } : customSettings, + }; + } + } + + registry.register(fieldConfigProp); + } + + return registry; +} diff --git a/packages/grafana-data/src/text/index.ts b/packages/grafana-data/src/text/index.ts new file mode 100644 index 0000000..bece689 --- /dev/null +++ b/packages/grafana-data/src/text/index.ts @@ -0,0 +1,11 @@ +export * from './string'; +export * from './markdown'; +export * from './text'; +import { escapeHtml, hasAnsiCodes, sanitize, sanitizeUrl } from './sanitize'; + +export const textUtil = { + escapeHtml, + hasAnsiCodes, + sanitize, + sanitizeUrl, +}; diff --git a/packages/grafana-data/src/text/markdown.test.ts b/packages/grafana-data/src/text/markdown.test.ts new file mode 100644 index 0000000..ed51f2c --- /dev/null +++ b/packages/grafana-data/src/text/markdown.test.ts @@ -0,0 +1,13 @@ +import { renderMarkdown } from './markdown'; + +describe('Markdown wrapper', () => { + it('should be able to handle undefined value', () => { + const str = renderMarkdown(undefined); + expect(str).toBe(''); + }); + + it('should sanitize by default', () => { + const str = renderMarkdown(''); + expect(str).toBe('<script>alert()</script>'); + }); +}); diff --git a/packages/grafana-data/src/text/markdown.ts b/packages/grafana-data/src/text/markdown.ts new file mode 100644 index 0000000..4c9b369 --- /dev/null +++ b/packages/grafana-data/src/text/markdown.ts @@ -0,0 +1,28 @@ +import marked from 'marked'; +import { sanitize } from './sanitize'; + +let hasInitialized = false; + +export interface RenderMarkdownOptions { + noSanitize?: boolean; +} + +export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): string { + if (!hasInitialized) { + marked.setOptions({ + pedantic: false, + gfm: true, + smartLists: true, + smartypants: false, + xhtml: false, + }); + hasInitialized = true; + } + + const html = marked(str || ''); + if (options?.noSanitize) { + return html; + } + + return sanitize(html); +} diff --git a/packages/grafana-data/src/text/sanitize.ts b/packages/grafana-data/src/text/sanitize.ts new file mode 100644 index 0000000..d32c4a9 --- /dev/null +++ b/packages/grafana-data/src/text/sanitize.ts @@ -0,0 +1,40 @@ +import xss from 'xss'; +import { sanitizeUrl as braintreeSanitizeUrl } from '@braintree/sanitize-url'; + +const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => { + // @ts-ignore + acc[element] = xss.whiteList[element].concat(['class', 'style']); + return acc; +}, {}); + +const sanitizeXSS = new xss.FilterXSS({ + whiteList: XSSWL, +}); + +/** + * Returns string safe from XSS attacks. + * + * Even though we allow the style-attribute, there's still default filtering applied to it + * Info: https://github.com/leizongmin/js-xss#customize-css-filter + * Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js + */ +export function sanitize(unsanitizedString: string): string { + try { + return sanitizeXSS.process(unsanitizedString); + } catch (error) { + console.error('String could not be sanitized', unsanitizedString); + return unsanitizedString; + } +} + +export function sanitizeUrl(url: string): string { + return braintreeSanitizeUrl(url); +} + +export function hasAnsiCodes(input: string): boolean { + return /\u001b\[\d{1,2}m/.test(input); +} + +export function escapeHtml(str: string): string { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/packages/grafana-data/src/text/string.test.ts b/packages/grafana-data/src/text/string.test.ts new file mode 100644 index 0000000..b9cd3eb --- /dev/null +++ b/packages/grafana-data/src/text/string.test.ts @@ -0,0 +1,90 @@ +import { escapeStringForRegex, stringToJsRegex, stringToMs, unEscapeStringFromRegex } from './string'; + +describe('stringToJsRegex', () => { + it('should just return string as RegEx if it does not start as a regex', () => { + const output = stringToJsRegex('validRegexp'); + expect(output).toBeInstanceOf(RegExp); + }); + + it('should parse the valid regex value', () => { + const output = stringToJsRegex('/validRegexp/'); + expect(output).toBeInstanceOf(RegExp); + }); + + it('should throw error on invalid regex value', () => { + const input = '/etc/hostname'; + expect(() => { + stringToJsRegex(input); + }).toThrow(); + }); +}); + +describe('stringToMs', () => { + it('should return zero if no input', () => { + const output = stringToMs(''); + expect(output).toBe(0); + }); + + it('should return its input, as int, if no unit is supplied', () => { + const output = stringToMs('1000'); + expect(output).toBe(1000); + }); + + it('should convert 3s to 3000', () => { + const output = stringToMs('3s'); + expect(output).toBe(3000); + }); + + it('should convert 2m to 120000', () => { + const output = stringToMs('2m'); + expect(output).toBe(120000); + }); + + it('should convert 2h to 7200000', () => { + const output = stringToMs('2h'); + expect(output).toBe(7200000); + }); + + it('should convert 2d to 172800000', () => { + const output = stringToMs('2d'); + expect(output).toBe(172800000); + }); + + it('should throw on unsupported unit', () => { + expect(() => { + stringToMs('1y'); + }).toThrow(); + }); +}); + +describe('escapeStringForRegex', () => { + describe('when using a string with special chars', () => { + it('then all special chars should be escaped', () => { + const result = escapeStringForRegex('([{}])|*+-.?<>#&^$'); + expect(result).toBe('\\(\\[\\{\\}\\]\\)\\|\\*\\+\\-\\.\\?\\<\\>\\#\\&\\^\\$'); + }); + }); + + describe('when using a string without special chars', () => { + it('then nothing should change', () => { + const result = escapeStringForRegex('some string 123'); + expect(result).toBe('some string 123'); + }); + }); +}); + +describe('unEscapeStringFromRegex', () => { + describe('when using a string with escaped special chars', () => { + it('then all special chars should be unescaped', () => { + const result = unEscapeStringFromRegex('\\(\\[\\{\\}\\]\\)\\|\\*\\+\\-\\.\\?\\<\\>\\#\\&\\^\\$'); + expect(result).toBe('([{}])|*+-.?<>#&^$'); + }); + }); + + describe('when using a string without escaped special chars', () => { + it('then nothing should change', () => { + const result = unEscapeStringFromRegex('some string 123'); + expect(result).toBe('some string 123'); + }); + }); +}); diff --git a/packages/grafana-data/src/text/string.ts b/packages/grafana-data/src/text/string.ts new file mode 100644 index 0000000..79cf3ae --- /dev/null +++ b/packages/grafana-data/src/text/string.ts @@ -0,0 +1,97 @@ +import { camelCase } from 'lodash'; +const specialChars = ['(', '[', '{', '}', ']', ')', '|', '*', '+', '-', '.', '?', '<', '>', '#', '&', '^', '$']; + +export const escapeStringForRegex = (value: string) => { + if (!value) { + return value; + } + + return specialChars.reduce((escaped, currentChar) => escaped.replace(currentChar, '\\' + currentChar), value); +}; + +export const unEscapeStringFromRegex = (value: string) => { + if (!value) { + return value; + } + + return specialChars.reduce((escaped, currentChar) => escaped.replace('\\' + currentChar, currentChar), value); +}; + +export function stringStartsAsRegEx(str: string): boolean { + if (!str) { + return false; + } + + return str[0] === '/'; +} + +export function stringToJsRegex(str: string): RegExp { + if (!stringStartsAsRegEx(str)) { + return new RegExp(`^${str}$`); + } + + const match = str.match(new RegExp('^/(.*?)/(g?i?m?y?)$')); + + if (!match) { + throw new Error(`'${str}' is not a valid regular expression.`); + } + + return new RegExp(match[1], match[2]); +} + +export function stringToMs(str: string): number { + if (!str) { + return 0; + } + + const nr = parseInt(str, 10); + const unit = str.substr(String(nr).length); + const s = 1000; + const m = s * 60; + const h = m * 60; + const d = h * 24; + + switch (unit) { + case 's': + return nr * s; + case 'm': + return nr * m; + case 'h': + return nr * h; + case 'd': + return nr * d; + default: + if (!unit) { + return isNaN(nr) ? 0 : nr; + } + throw new Error('Not supported unit: ' + unit); + } +} + +export function toNumberString(value: number | undefined | null): string { + if (value !== null && value !== undefined && Number.isFinite(value as number)) { + return value.toString(); + } + return ''; +} + +export function toIntegerOrUndefined(value: string): number | undefined { + if (!value) { + return undefined; + } + const v = parseInt(value, 10); + return isNaN(v) ? undefined : v; +} + +export function toFloatOrUndefined(value: string): number | undefined { + if (!value) { + return undefined; + } + const v = parseFloat(value); + return isNaN(v) ? undefined : v; +} + +export const toPascalCase = (string: string) => { + const str = camelCase(string); + return str.charAt(0).toUpperCase() + str.substring(1); +}; diff --git a/packages/grafana-data/src/text/text.test.ts b/packages/grafana-data/src/text/text.test.ts new file mode 100644 index 0000000..624fbc6 --- /dev/null +++ b/packages/grafana-data/src/text/text.test.ts @@ -0,0 +1,68 @@ +import { findMatchesInText, parseFlags } from './text'; + +describe('findMatchesInText()', () => { + it('gets no matches for when search and or line are empty', () => { + expect(findMatchesInText('', '')).toEqual([]); + expect(findMatchesInText('foo', '')).toEqual([]); + expect(findMatchesInText('', 'foo')).toEqual([]); + }); + + it('gets no matches for unmatched search string', () => { + expect(findMatchesInText('foo', 'bar')).toEqual([]); + }); + + it('gets matches for matched search string', () => { + expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]); + expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]); + }); + + test('should find all matches for a complete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + { length: 3, start: 9, text: 'bar', end: 12 }, + ]); + }); + + test('not fail on incomplete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + ]); + expect(findMatchesInText('foo foo bar', '(')).toEqual([]); + expect(findMatchesInText('foo foo bar', '(foo|')).toEqual([]); + }); + + test('should parse and use flags', () => { + expect(findMatchesInText(' foo FOO bar ', '(?i)foo')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'FOO', end: 8 }, + ]); + expect(findMatchesInText(' foo FOO bar ', '(?i)(?-i)foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]); + expect(findMatchesInText('FOO\nfoobar\nbar', '(?ims)^foo.')).toEqual([ + { length: 4, start: 0, text: 'FOO\n', end: 4 }, + { length: 4, start: 4, text: 'foob', end: 8 }, + ]); + expect(findMatchesInText('FOO\nfoobar\nbar', '(?ims)(?-smi)^foo.')).toEqual([]); + }); +}); + +describe('parseFlags()', () => { + it('when no flags or text', () => { + expect(parseFlags('')).toEqual({ cleaned: '', flags: 'g' }); + expect(parseFlags('(?is)')).toEqual({ cleaned: '', flags: 'gis' }); + expect(parseFlags('foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + }); + + it('when flags present', () => { + expect(parseFlags('(?i)foo')).toEqual({ cleaned: 'foo', flags: 'gi' }); + expect(parseFlags('(?ims)foo')).toEqual({ cleaned: 'foo', flags: 'gims' }); + }); + + it('when flags cancel each other', () => { + expect(parseFlags('(?i)(?-i)foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + expect(parseFlags('(?i-i)foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + expect(parseFlags('(?is)(?-ims)foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + expect(parseFlags('(?i)(?-i)(?i)foo')).toEqual({ cleaned: 'foo', flags: 'gi' }); + }); +}); diff --git a/packages/grafana-data/src/text/text.ts b/packages/grafana-data/src/text/text.ts new file mode 100644 index 0000000..5b98ba1 --- /dev/null +++ b/packages/grafana-data/src/text/text.ts @@ -0,0 +1,88 @@ +export interface TextMatch { + text: string; + start: number; + length: number; + end: number; +} + +/** + * Adapt findMatchesInText for react-highlight-words findChunks handler. + * See https://github.com/bvaughn/react-highlight-words#props + */ +export function findHighlightChunksInText({ + searchWords, + textToHighlight, +}: { + searchWords: Array; + textToHighlight: string; +}) { + const chunks: TextMatch[] = []; + for (const term of searchWords) { + chunks.push(...findMatchesInText(textToHighlight, term as string)); + } + return chunks; +} + +const cleanNeedle = (needle: string): string => { + return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); +}; + +/** + * Returns a list of substring regexp matches. + */ +export function findMatchesInText(haystack: string, needle: string): TextMatch[] { + // Empty search can send re.exec() into infinite loop, exit early + if (!haystack || !needle) { + return []; + } + const matches: TextMatch[] = []; + const { cleaned, flags } = parseFlags(cleanNeedle(needle)); + let regexp: RegExp; + try { + regexp = new RegExp(`(?:${cleaned})`, flags); + } catch (error) { + return matches; + } + haystack.replace(regexp, (substring, ...rest) => { + if (substring) { + const offset = rest[rest.length - 2]; + matches.push({ + text: substring, + start: offset, + length: substring.length, + end: offset + substring.length, + }); + } + return ''; + }); + return matches; +} + +const CLEAR_FLAG = '-'; +const FLAGS_REGEXP = /\(\?([ims-]+)\)/g; + +/** + * Converts any mode modifiers in the text to the Javascript equivalent flag + */ +export function parseFlags(text: string): { cleaned: string; flags: string } { + const flags: Set = new Set(['g']); + + const cleaned = text.replace(FLAGS_REGEXP, (str, group) => { + const clearAll = group.startsWith(CLEAR_FLAG); + + for (let i = 0; i < group.length; ++i) { + const flag = group.charAt(i); + if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) { + flags.delete(flag); + } else if (flag !== CLEAR_FLAG) { + flags.add(flag); + } + } + return ''; // Remove flag from text + }); + + return { + cleaned: cleaned, + flags: Array.from(flags).join(''), + }; +} diff --git a/packages/grafana-data/src/themes/breakpoints.ts b/packages/grafana-data/src/themes/breakpoints.ts new file mode 100644 index 0000000..4d6e1f6 --- /dev/null +++ b/packages/grafana-data/src/themes/breakpoints.ts @@ -0,0 +1,56 @@ +/** @beta */ +export interface ThemeBreakpointValues { + xs: number; + sm: number; + md: number; + lg: number; + xl: number; + xxl: number; +} + +/** @beta */ +export type ThemeBreakpointsKey = keyof ThemeBreakpointValues; + +/** @beta */ +export interface ThemeBreakpoints { + values: ThemeBreakpointValues; + keys: string[]; + unit: string; + up: (key: ThemeBreakpointsKey) => string; + down: (key: ThemeBreakpointsKey) => string; +} + +/** @internal */ +export function createBreakpoints(): ThemeBreakpoints { + const step = 5; + const keys = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + const unit = 'px'; + const values: ThemeBreakpointValues = { + xs: 0, + sm: 544, + md: 769, // 1 more than regular ipad in portrait + lg: 992, + xl: 1200, + xxl: 1440, + }; + + function up(key: ThemeBreakpointsKey | number) { + const value = typeof key === 'number' ? key : values[key]; + return `@media (min-width:${value}${unit})`; + } + + function down(key: ThemeBreakpointsKey | number) { + const value = typeof key === 'number' ? key : values[key]; + return `@media (max-width:${value - step / 100}${unit})`; + } + + // TODO add functions for between and only + + return { + values, + up, + down, + keys, + unit, + }; +} diff --git a/packages/grafana-data/src/themes/colorManipulator.test.ts b/packages/grafana-data/src/themes/colorManipulator.test.ts new file mode 100644 index 0000000..7982fd1 --- /dev/null +++ b/packages/grafana-data/src/themes/colorManipulator.test.ts @@ -0,0 +1,402 @@ +import { + recomposeColor, + hexToRgb, + rgbToHex, + hslToRgb, + darken, + decomposeColor, + emphasize, + alpha, + getContrastRatio, + getLuminance, + lighten, +} from './colorManipulator'; + +describe('utils/colorManipulator', () => { + const origError = console.error; + const consoleErrorMock = jest.fn(); + afterEach(() => (console.error = origError)); + beforeEach(() => (console.error = consoleErrorMock)); + + describe('recomposeColor', () => { + it('converts a decomposed rgb color object to a string` ', () => { + expect( + recomposeColor({ + type: 'rgb', + values: [255, 255, 255], + }) + ).toEqual('rgb(255, 255, 255)'); + }); + + it('converts a decomposed rgba color object to a string` ', () => { + expect( + recomposeColor({ + type: 'rgba', + values: [255, 255, 255, 0.5], + }) + ).toEqual('rgba(255, 255, 255, 0.5)'); + }); + + it('converts a decomposed CSS4 color object to a string` ', () => { + expect( + recomposeColor({ + type: 'color', + colorSpace: 'display-p3', + values: [0.5, 0.3, 0.2], + }) + ).toEqual('color(display-p3 0.5 0.3 0.2)'); + }); + + it('converts a decomposed hsl color object to a string` ', () => { + expect( + recomposeColor({ + type: 'hsl', + values: [100, 50, 25], + }) + ).toEqual('hsl(100, 50%, 25%)'); + }); + + it('converts a decomposed hsla color object to a string` ', () => { + expect( + recomposeColor({ + type: 'hsla', + values: [100, 50, 25, 0.5], + }) + ).toEqual('hsla(100, 50%, 25%, 0.5)'); + }); + }); + + describe('hexToRgb', () => { + it('converts a short hex color to an rgb color` ', () => { + expect(hexToRgb('#9f3')).toEqual('rgb(153, 255, 51)'); + }); + + it('converts a long hex color to an rgb color` ', () => { + expect(hexToRgb('#a94fd3')).toEqual('rgb(169, 79, 211)'); + }); + + it('converts a long alpha hex color to an argb color` ', () => { + expect(hexToRgb('#111111f8')).toEqual('rgba(17, 17, 17, 0.973)'); + }); + }); + + describe('rgbToHex', () => { + it('converts an rgb color to a hex color` ', () => { + expect(rgbToHex('rgb(169, 79, 211)')).toEqual('#a94fd3'); + }); + + it('idempotent', () => { + expect(rgbToHex('#A94FD3')).toEqual('#A94FD3'); + }); + }); + + describe('hslToRgb', () => { + it('converts an hsl color to an rgb color` ', () => { + expect(hslToRgb('hsl(281, 60%, 57%)')).toEqual('rgb(169, 80, 211)'); + }); + + it('converts an hsla color to an rgba color` ', () => { + expect(hslToRgb('hsla(281, 60%, 57%, 0.5)')).toEqual('rgba(169, 80, 211, 0.5)'); + }); + + it('allow to convert values only', () => { + expect(hslToRgb(decomposeColor('hsl(281, 60%, 57%)'))).toEqual('rgb(169, 80, 211)'); + }); + }); + + describe('decomposeColor', () => { + it('converts an rgb color string to an object with `type` and `value` keys', () => { + const { type, values } = decomposeColor('rgb(255, 255, 255)'); + expect(type).toEqual('rgb'); + expect(values).toEqual([255, 255, 255]); + }); + + it('converts an rgba color string to an object with `type` and `value` keys', () => { + const { type, values } = decomposeColor('rgba(255, 255, 255, 0.5)'); + expect(type).toEqual('rgba'); + expect(values).toEqual([255, 255, 255, 0.5]); + }); + + it('converts an hsl color string to an object with `type` and `value` keys', () => { + const { type, values } = decomposeColor('hsl(100, 50%, 25%)'); + expect(type).toEqual('hsl'); + expect(values).toEqual([100, 50, 25]); + }); + + it('converts an hsla color string to an object with `type` and `value` keys', () => { + const { type, values } = decomposeColor('hsla(100, 50%, 25%, 0.5)'); + expect(type).toEqual('hsla'); + expect(values).toEqual([100, 50, 25, 0.5]); + }); + + it('converts CSS4 color with color space display-3', () => { + const { type, values, colorSpace } = decomposeColor('color(display-p3 0 1 0)'); + expect(type).toEqual('color'); + expect(colorSpace).toEqual('display-p3'); + expect(values).toEqual([0, 1, 0]); + }); + + it('converts an alpha CSS4 color with color space display-3', () => { + const { type, values, colorSpace } = decomposeColor('color(display-p3 0 1 0 /0.4)'); + expect(type).toEqual('color'); + expect(colorSpace).toEqual('display-p3'); + expect(values).toEqual([0, 1, 0, 0.4]); + }); + + it('should throw error with inexistent color color space', () => { + const decimposeWithError = () => decomposeColor('color(foo 0 1 0)'); + expect(decimposeWithError).toThrow(); + }); + + it('idempotent', () => { + const output1 = decomposeColor('hsla(100, 50%, 25%, 0.5)'); + const output2 = decomposeColor(output1); + expect(output1).toEqual(output2); + }); + + it('converts rgba hex', () => { + const decomposed = decomposeColor('#111111f8'); + expect(decomposed).toEqual({ + type: 'rgba', + colorSpace: undefined, + values: [17, 17, 17, 0.973], + }); + }); + }); + + describe('getContrastRatio', () => { + it('returns a ratio for black : white', () => { + expect(getContrastRatio('#000', '#FFF')).toEqual(21); + }); + + it('returns a ratio for black : black', () => { + expect(getContrastRatio('#000', '#000')).toEqual(1); + }); + + it('returns a ratio for white : white', () => { + expect(getContrastRatio('#FFF', '#FFF')).toEqual(1); + }); + + it('returns a ratio for dark-grey : light-grey', () => { + //expect(getContrastRatio('#707070', '#E5E5E5'))to.be.approximately(3.93, 0.01); + }); + + it('returns a ratio for black : light-grey', () => { + //expect(getContrastRatio('#000', '#888')).to.be.approximately(5.92, 0.01); + }); + }); + + describe('getLuminance', () => { + it('returns a valid luminance for rgb black', () => { + expect(getLuminance('rgba(0, 0, 0)')).toEqual(0); + expect(getLuminance('rgb(0, 0, 0)')).toEqual(0); + expect(getLuminance('color(display-p3 0 0 0)')).toEqual(0); + }); + + it('returns a valid luminance for rgb white', () => { + expect(getLuminance('rgba(255, 255, 255)')).toEqual(1); + expect(getLuminance('rgb(255, 255, 255)')).toEqual(1); + }); + + it('returns a valid luminance for rgb mid-grey', () => { + expect(getLuminance('rgba(127, 127, 127)')).toEqual(0.212); + expect(getLuminance('rgb(127, 127, 127)')).toEqual(0.212); + }); + + it('returns a valid luminance for an rgb color', () => { + expect(getLuminance('rgb(255, 127, 0)')).toEqual(0.364); + }); + + it('returns a valid luminance from an hsl color', () => { + expect(getLuminance('hsl(100, 100%, 50%)')).toEqual(0.735); + }); + + it('returns an equal luminance for the same color in different formats', () => { + const hsl = 'hsl(100, 100%, 50%)'; + const rgb = 'rgb(85, 255, 0)'; + expect(getLuminance(hsl)).toEqual(getLuminance(rgb)); + }); + + it('returns a valid luminance from an CSS4 color', () => { + expect(getLuminance('color(display-p3 1 1 0.1)')).toEqual(0.929); + }); + + it('throw on invalid colors', () => { + expect(() => { + getLuminance('black'); + }).toThrowError(/Unsupported 'black' color/); + }); + }); + + describe('emphasize', () => { + it('lightens a dark rgb color with the coefficient provided', () => { + expect(emphasize('rgb(1, 2, 3)', 0.4)).toEqual(lighten('rgb(1, 2, 3)', 0.4)); + }); + + it('darkens a light rgb color with the coefficient provided', () => { + expect(emphasize('rgb(250, 240, 230)', 0.3)).toEqual(darken('rgb(250, 240, 230)', 0.3)); + }); + + it('lightens a dark rgb color with the coefficient 0.15 by default', () => { + expect(emphasize('rgb(1, 2, 3)')).toEqual(lighten('rgb(1, 2, 3)', 0.15)); + }); + + it('darkens a light rgb color with the coefficient 0.15 by default', () => { + expect(emphasize('rgb(250, 240, 230)')).toEqual(darken('rgb(250, 240, 230)', 0.15)); + }); + + it('lightens a dark CSS4 color with the coefficient 0.15 by default', () => { + expect(emphasize('color(display-p3 0.1 0.1 0.1)')).toEqual(lighten('color(display-p3 0.1 0.1 0.1)', 0.15)); + }); + + it('darkens a light CSS4 color with the coefficient 0.15 by default', () => { + expect(emphasize('color(display-p3 1 1 0.1)')).toEqual(darken('color(display-p3 1 1 0.1)', 0.15)); + }); + }); + + describe('alpha', () => { + it('converts an rgb color to an rgba color with the value provided', () => { + expect(alpha('rgb(1, 2, 3)', 0.4)).toEqual('rgba(1, 2, 3, 0.4)'); + }); + + it('updates an CSS4 color with the alpha value provided', () => { + expect(alpha('color(display-p3 1 2 3)', 0.4)).toEqual('color(display-p3 1 2 3 /0.4)'); + }); + + it('updates an rgba color with the alpha value provided', () => { + expect(alpha('rgba(255, 0, 0, 0.2)', 0.5)).toEqual('rgba(255, 0, 0, 0.5)'); + }); + + it('converts an hsl color to an hsla color with the value provided', () => { + expect(alpha('hsl(0, 100%, 50%)', 0.1)).toEqual('hsla(0, 100%, 50%, 0.1)'); + }); + + it('updates an hsla color with the alpha value provided', () => { + expect(alpha('hsla(0, 100%, 50%, 0.2)', 0.5)).toEqual('hsla(0, 100%, 50%, 0.5)'); + }); + + it('throw on invalid colors', () => { + expect(() => { + alpha('white', 0.4); + }).toThrowError(/Unsupported 'white' color/); + }); + }); + + describe('darken', () => { + it("doesn't modify rgb black", () => { + expect(darken('rgb(0, 0, 0)', 0.1)).toEqual('rgb(0, 0, 0)'); + }); + + it("doesn't overshoot if an above-range coefficient is supplied", () => { + expect(darken('rgb(0, 127, 255)', 1.5)).toEqual('rgb(0, 0, 0)'); + expect(consoleErrorMock).toHaveBeenCalledWith('The value provided 1.5 is out of range [0, 1].'); + }); + + it("doesn't overshoot if a below-range coefficient is supplied", () => { + expect(darken('rgb(0, 127, 255)', -0.1)).toEqual('rgb(0, 127, 255)'); + expect(consoleErrorMock).toHaveBeenCalledWith('The value provided 1.5 is out of range [0, 1].'); + }); + + it('darkens rgb white to black when coefficient is 1', () => { + expect(darken('rgb(255, 255, 255)', 1)).toEqual('rgb(0, 0, 0)'); + }); + + it('retains the alpha value in an rgba color', () => { + expect(darken('rgb(0, 0, 0, 0.5)', 0.1)).toEqual('rgb(0, 0, 0, 0.5)'); + }); + + it('darkens rgb white by 10% when coefficient is 0.1', () => { + expect(darken('rgb(255, 255, 255)', 0.1)).toEqual('rgb(229, 229, 229)'); + }); + + it('darkens rgb red by 50% when coefficient is 0.5', () => { + expect(darken('rgb(255, 0, 0)', 0.5)).toEqual('rgb(127, 0, 0)'); + }); + + it('darkens rgb grey by 50% when coefficient is 0.5', () => { + expect(darken('rgb(127, 127, 127)', 0.5)).toEqual('rgb(63, 63, 63)'); + }); + + it("doesn't modify rgb colors when coefficient is 0", () => { + expect(darken('rgb(255, 255, 255)', 0)).toEqual('rgb(255, 255, 255)'); + }); + + it('darkens hsl red by 50% when coefficient is 0.5', () => { + expect(darken('hsl(0, 100%, 50%)', 0.5)).toEqual('hsl(0, 100%, 25%)'); + }); + + it("doesn't modify hsl colors when coefficient is 0", () => { + expect(darken('hsl(0, 100%, 50%)', 0)).toEqual('hsl(0, 100%, 50%)'); + }); + + it("doesn't modify hsl colors when l is 0%", () => { + expect(darken('hsl(0, 50%, 0%)', 0.5)).toEqual('hsl(0, 50%, 0%)'); + }); + + it('darkens CSS4 color red by 50% when coefficient is 0.5', () => { + expect(darken('color(display-p3 1 0 0)', 0.5)).toEqual('color(display-p3 0.5 0 0)'); + }); + + it("doesn't modify CSS4 color when coefficient is 0", () => { + expect(darken('color(display-p3 1 0 0)', 0)).toEqual('color(display-p3 1 0 0)'); + }); + }); + + describe('lighten', () => { + it("doesn't modify rgb white", () => { + expect(lighten('rgb(255, 255, 255)', 0.1)).toEqual('rgb(255, 255, 255)'); + }); + + it("doesn't overshoot if an above-range coefficient is supplied", () => { + expect(lighten('rgb(0, 127, 255)', 1.5)).toEqual('rgb(255, 255, 255)'); + }); + + it("doesn't overshoot if a below-range coefficient is supplied", () => { + expect(lighten('rgb(0, 127, 255)', -0.1)).toEqual('rgb(0, 127, 255)'); + }); + + it('lightens rgb black to white when coefficient is 1', () => { + expect(lighten('rgb(0, 0, 0)', 1)).toEqual('rgb(255, 255, 255)'); + }); + + it('retains the alpha value in an rgba color', () => { + expect(lighten('rgb(255, 255, 255, 0.5)', 0.1)).toEqual('rgb(255, 255, 255, 0.5)'); + }); + + it('lightens rgb black by 10% when coefficient is 0.1', () => { + expect(lighten('rgb(0, 0, 0)', 0.1)).toEqual('rgb(25, 25, 25)'); + }); + + it('lightens rgb red by 50% when coefficient is 0.5', () => { + expect(lighten('rgb(255, 0, 0)', 0.5)).toEqual('rgb(255, 127, 127)'); + }); + + it('lightens rgb grey by 50% when coefficient is 0.5', () => { + expect(lighten('rgb(127, 127, 127)', 0.5)).toEqual('rgb(191, 191, 191)'); + }); + + it("doesn't modify rgb colors when coefficient is 0", () => { + expect(lighten('rgb(127, 127, 127)', 0)).toEqual('rgb(127, 127, 127)'); + }); + + it('lightens hsl red by 50% when coefficient is 0.5', () => { + expect(lighten('hsl(0, 100%, 50%)', 0.5)).toEqual('hsl(0, 100%, 75%)'); + }); + + it("doesn't modify hsl colors when coefficient is 0", () => { + expect(lighten('hsl(0, 100%, 50%)', 0)).toEqual('hsl(0, 100%, 50%)'); + }); + + it("doesn't modify hsl colors when `l` is 100%", () => { + expect(lighten('hsl(0, 50%, 100%)', 0.5)).toEqual('hsl(0, 50%, 100%)'); + }); + + it('lightens CSS4 color red by 50% when coefficient is 0.5', () => { + expect(lighten('color(display-p3 1 0 0)', 0.5)).toEqual('color(display-p3 1 0.5 0.5)'); + }); + + it("doesn't modify CSS4 color when coefficient is 0", () => { + expect(lighten('color(display-p3 1 0 0)', 0)).toEqual('color(display-p3 1 0 0)'); + }); + }); +}); diff --git a/packages/grafana-data/src/themes/colorManipulator.ts b/packages/grafana-data/src/themes/colorManipulator.ts new file mode 100644 index 0000000..0b76bbc --- /dev/null +++ b/packages/grafana-data/src/themes/colorManipulator.ts @@ -0,0 +1,298 @@ +// Code based on Material-UI +// https://github.com/mui-org/material-ui/blob/1b096070faf102281f8e3c4f9b2bf50acf91f412/packages/material-ui/src/styles/colorManipulator.js#L97 +// MIT License Copyright (c) 2014 Call-Em-All + +/** + * Returns a number whose value is limited to the given range. + * @param value The value to be clamped + * @param min The lower boundary of the output range + * @param max The upper boundary of the output range + * @returns A number in the range [min, max] + * @beta + */ +function clamp(value: number, min = 0, max = 1) { + if (process.env.NODE_ENV !== 'production') { + if (value < min || value > max) { + console.error(`The value provided ${value} is out of range [${min}, ${max}].`); + } + } + + return Math.min(Math.max(min, value), max); +} + +/** + * Converts a color from CSS hex format to CSS rgb format. + * @param color - Hex color, i.e. #nnn or #nnnnnn + * @returns A CSS rgb color string + * @beta + */ +export function hexToRgb(color: string) { + color = color.substr(1); + + const re = new RegExp(`.{1,${color.length >= 6 ? 2 : 1}}`, 'g'); + let colors = color.match(re); + + if (colors && colors[0].length === 1) { + colors = colors.map((n) => n + n); + } + + return colors + ? `rgb${colors.length === 4 ? 'a' : ''}(${colors + .map((n, index) => { + return index < 3 ? parseInt(n, 16) : Math.round((parseInt(n, 16) / 255) * 1000) / 1000; + }) + .join(', ')})` + : ''; +} + +function intToHex(int: number) { + const hex = int.toString(16); + return hex.length === 1 ? `0${hex}` : hex; +} + +/** + * Converts a color from CSS rgb format to CSS hex format. + * @param color - RGB color, i.e. rgb(n, n, n) + * @returns A CSS rgb color string, i.e. #nnnnnn + * @beta + */ +export function rgbToHex(color: string) { + // Idempotent + if (color.indexOf('#') === 0) { + return color; + } + + const { values } = decomposeColor(color); + return `#${values.map((n: number) => intToHex(n)).join('')}`; +} + +/** + * Converts a color from hsl format to rgb format. + * @param color - HSL color values + * @returns rgb color values + * @beta + */ +export function hslToRgb(color: string | DecomposeColor) { + const parts = decomposeColor(color); + const { values } = parts; + const h = values[0]; + const s = values[1] / 100; + const l = values[2] / 100; + const a = s * Math.min(l, 1 - l); + const f = (n: number, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + + let type = 'rgb'; + const rgb = [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; + + if (parts.type === 'hsla') { + type += 'a'; + rgb.push(values[3]); + } + + return recomposeColor({ type, values: rgb }); +} + +/** + * Returns an object with the type and values of a color. + * + * Note: Does not support rgb % values. + * @param color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla() + * @returns {object} - A MUI color object: {type: string, values: number[]} + * @beta + */ +export function decomposeColor(color: string | DecomposeColor): DecomposeColor { + // Idempotent + if (typeof color !== 'string') { + return color; + } + + if (color.charAt(0) === '#') { + return decomposeColor(hexToRgb(color)); + } + + const marker = color.indexOf('('); + const type = color.substring(0, marker); + + if (['rgb', 'rgba', 'hsl', 'hsla', 'color'].indexOf(type) === -1) { + throw new Error( + `Unsupported '${color}' color. The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()` + ); + } + + let values: any = color.substring(marker + 1, color.length - 1); + let colorSpace; + + if (type === 'color') { + values = values.split(' '); + colorSpace = values.shift(); + if (values.length === 4 && values[3].charAt(0) === '/') { + values[3] = values[3].substr(1); + } + if (['srgb', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec-2020'].indexOf(colorSpace) === -1) { + throw new Error( + `Unsupported ${colorSpace} color space. The following color spaces are supported: srgb, display-p3, a98-rgb, prophoto-rgb, rec-2020.` + ); + } + } else { + values = values.split(','); + } + + values = values.map((value: string) => parseFloat(value)); + return { type, values, colorSpace }; +} + +/** + * Converts a color object with type and values to a string. + * @param {object} color - Decomposed color + * @param color.type - One of: 'rgb', 'rgba', 'hsl', 'hsla' + * @param {array} color.values - [n,n,n] or [n,n,n,n] + * @returns A CSS color string + * @beta + */ +export function recomposeColor(color: DecomposeColor) { + const { type, colorSpace } = color; + let values: any = color.values; + + if (type.indexOf('rgb') !== -1) { + // Only convert the first 3 values to int (i.e. not alpha) + values = values.map((n: string, i: number) => (i < 3 ? parseInt(n, 10) : n)); + } else if (type.indexOf('hsl') !== -1) { + values[1] = `${values[1]}%`; + values[2] = `${values[2]}%`; + } + if (type.indexOf('color') !== -1) { + values = `${colorSpace} ${values.join(' ')}`; + } else { + values = `${values.join(', ')}`; + } + + return `${type}(${values})`; +} + +/** + * Calculates the contrast ratio between two colors. + * + * Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests + * @param foreground - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla() + * @param background - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla() + * @returns A contrast ratio value in the range 0 - 21. + * @beta + */ +export function getContrastRatio(foreground: string, background: string) { + const lumA = getLuminance(foreground); + const lumB = getLuminance(background); + return (Math.max(lumA, lumB) + 0.05) / (Math.min(lumA, lumB) + 0.05); +} + +/** + * The relative brightness of any point in a color space, + * normalized to 0 for darkest black and 1 for lightest white. + * + * Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests + * @param color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + * @returns The relative brightness of the color in the range 0 - 1 + * @beta + */ +export function getLuminance(color: string) { + const parts = decomposeColor(color); + + let rgb = parts.type === 'hsl' ? decomposeColor(hslToRgb(color)).values : parts.values; + const rgbNumbers = rgb.map((val: any) => { + if (parts.type !== 'color') { + val /= 255; // normalized + } + return val <= 0.03928 ? val / 12.92 : ((val + 0.055) / 1.055) ** 2.4; + }); + + // Truncate at 3 digits + return Number((0.2126 * rgbNumbers[0] + 0.7152 * rgbNumbers[1] + 0.0722 * rgbNumbers[2]).toFixed(3)); +} + +/** + * Darken or lighten a color, depending on its luminance. + * Light colors are darkened, dark colors are lightened. + * @param color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + * @param coefficient=0.15 - multiplier in the range 0 - 1 + * @returns A CSS color string. Hex input values are returned as rgb + * @beta + */ +export function emphasize(color: string, coefficient = 0.15) { + return getLuminance(color) > 0.5 ? darken(color, coefficient) : lighten(color, coefficient); +} + +/** + * Set the absolute transparency of a color. + * Any existing alpha values are overwritten. + * @param color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + * @param value - value to set the alpha channel to in the range 0 - 1 + * @returns A CSS color string. Hex input values are returned as rgb + * @beta + */ +export function alpha(color: string, value: number) { + const parts = decomposeColor(color); + value = clamp(value); + + if (parts.type === 'rgb' || parts.type === 'hsl') { + parts.type += 'a'; + } + if (parts.type === 'color') { + parts.values[3] = `/${value}`; + } else { + parts.values[3] = value; + } + + return recomposeColor(parts); +} + +/** + * Darkens a color. + * @param color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + * @param coefficient - multiplier in the range 0 - 1 + * @returns A CSS color string. Hex input values are returned as rgb + * @beta + */ +export function darken(color: string, coefficient: number) { + const parts = decomposeColor(color); + coefficient = clamp(coefficient); + + if (parts.type.indexOf('hsl') !== -1) { + parts.values[2] *= 1 - coefficient; + } else if (parts.type.indexOf('rgb') !== -1 || parts.type.indexOf('color') !== -1) { + for (let i = 0; i < 3; i += 1) { + parts.values[i] *= 1 - coefficient; + } + } + return recomposeColor(parts); +} + +/** + * Lightens a color. + * @param color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + * @param coefficient - multiplier in the range 0 - 1 + * @returns A CSS color string. Hex input values are returned as rgb + * @beta + */ +export function lighten(color: string, coefficient: number) { + const parts = decomposeColor(color); + coefficient = clamp(coefficient); + + if (parts.type.indexOf('hsl') !== -1) { + parts.values[2] += (100 - parts.values[2]) * coefficient; + } else if (parts.type.indexOf('rgb') !== -1) { + for (let i = 0; i < 3; i += 1) { + parts.values[i] += (255 - parts.values[i]) * coefficient; + } + } else if (parts.type.indexOf('color') !== -1) { + for (let i = 0; i < 3; i += 1) { + parts.values[i] += (1 - parts.values[i]) * coefficient; + } + } + + return recomposeColor(parts); +} + +interface DecomposeColor { + type: string; + values: any; + colorSpace?: string; +} diff --git a/packages/grafana-data/src/themes/createColors.test.ts b/packages/grafana-data/src/themes/createColors.test.ts new file mode 100644 index 0000000..a5a6de2 --- /dev/null +++ b/packages/grafana-data/src/themes/createColors.test.ts @@ -0,0 +1,17 @@ +import { createColors } from './createColors'; + +describe('createColors', () => { + it('Should enrich colors', () => { + const palette = createColors({}); + expect(palette.primary.name).toBe('primary'); + }); + + it('Should allow overrides', () => { + const palette = createColors({ + primary: { + main: '#FF0000', + }, + }); + expect(palette.primary.main).toBe('#FF0000'); + }); +}); diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts new file mode 100644 index 0000000..fc93ee6 --- /dev/null +++ b/packages/grafana-data/src/themes/createColors.ts @@ -0,0 +1,311 @@ +import { merge } from 'lodash'; +import { alpha, darken, emphasize, getContrastRatio, lighten } from './colorManipulator'; +import { palette } from './palette'; +import { DeepPartial, ThemeRichColor } from './types'; + +/** @internal */ +export type ThemeColorsMode = 'light' | 'dark'; + +/** @internal */ +export interface ThemeColorsBase { + mode: ThemeColorsMode; + + primary: TColor; + secondary: TColor; + info: TColor; + error: TColor; + success: TColor; + warning: TColor; + + text: { + primary: string; + secondary: string; + disabled: string; + link: string; + /** Used for auto white or dark text on colored backgrounds */ + maxContrast: string; + }; + + background: { + /** Dashboard and body background */ + canvas: string; + /** Primary content pane background (panels etc) */ + primary: string; + /** Cards and elements that need to stand out on the primary background */ + secondary: string; + }; + + border: { + weak: string; + medium: string; + strong: string; + }; + + gradients: { + brandVertical: string; + brandHorizontal: string; + }; + + action: { + /** Used for selected menu item / select option */ + selected: string; + /** Used for hovered menu item / select option */ + hover: string; + /** Used for button/colored background hover opacity */ + hoverOpacity: number; + /** Used focused menu item / select option */ + focus: string; + /** Used for disabled buttons and inputs */ + disabledBackground: string; + /** Disabled text */ + disabledText: string; + /** Disablerd opacity */ + disabledOpacity: number; + }; + + hoverFactor: number; + contrastThreshold: number; + tonalOffset: number; +} + +export interface ThemeHoverStrengh {} + +/** @beta */ +export interface ThemeColors extends ThemeColorsBase { + /** Returns a text color for the background */ + getContrastText(background: string): string; + /* Brighten or darken a color by specified factor (0-1) */ + emphasize(color: string, amount?: number): string; +} + +/** @internal */ +export type ThemeColorsInput = DeepPartial>; + +class DarkColors implements ThemeColorsBase> { + mode: ThemeColorsMode = 'dark'; + + // Used to get more white opacity colors + whiteBase = '201, 209, 217'; + + border = { + weak: `rgba(${this.whiteBase}, 0.08)`, + medium: `rgba(${this.whiteBase}, 0.15)`, + strong: `rgba(${this.whiteBase}, 0.25)`, + }; + + text = { + primary: `rgb(${this.whiteBase})`, + secondary: `rgba(${this.whiteBase}, 0.65)`, + disabled: `rgba(${this.whiteBase}, 0.40)`, + link: palette.blueDarkText, + maxContrast: palette.white, + }; + + primary = { + main: palette.blueDarkMain, + text: palette.blueDarkText, + border: palette.blueDarkText, + }; + + secondary = { + main: `rgba(${this.whiteBase}, 0.1)`, + shade: `rgba(${this.whiteBase}, 0.15)`, + text: this.text.primary, + contrastText: `rgb(${this.whiteBase})`, + border: this.border.strong, + }; + + info = this.primary; + + error = { + main: palette.redDarkMain, + text: palette.redDarkText, + }; + + success = { + main: palette.greenDarkMain, + text: palette.greenDarkText, + }; + + warning = { + main: palette.orangeDarkMain, + text: palette.orangeDarkText, + }; + + background = { + canvas: palette.gray05, + primary: palette.gray10, + secondary: palette.gray15, + }; + + action = { + hover: `rgba(${this.whiteBase}, 0.08)`, + selected: `rgba(${this.whiteBase}, 0.12)`, + focus: `rgba(${this.whiteBase}, 0.16)`, + hoverOpacity: 0.08, + disabledText: this.text.disabled, + disabledBackground: `rgba(${this.whiteBase}, 0.07)`, + disabledOpacity: 0.38, + }; + + gradients = { + brandHorizontal: ' linear-gradient(270deg, #F55F3E 0%, #FF8833 100%);', + brandVertical: 'linear-gradient(0.01deg, #F55F3E 0.01%, #FF8833 99.99%);', + }; + + contrastThreshold = 3; + hoverFactor = 0.03; + tonalOffset = 0.15; +} + +class LightColors implements ThemeColorsBase> { + mode: ThemeColorsMode = 'light'; + + blackBase = '36, 41, 46'; + + primary = { + main: palette.blueLightMain, + border: palette.blueLightText, + text: palette.blueLightText, + }; + + text = { + primary: `rgba(${this.blackBase}, 1)`, + secondary: `rgba(${this.blackBase}, 0.75)`, + disabled: `rgba(${this.blackBase}, 0.50)`, + link: this.primary.text, + maxContrast: palette.black, + }; + + border = { + weak: `rgba(${this.blackBase}, 0.12)`, + medium: `rgba(${this.blackBase}, 0.30)`, + strong: `rgba(${this.blackBase}, 0.40)`, + }; + + secondary = { + main: `rgba(${this.blackBase}, 0.11)`, + shade: `rgba(${this.blackBase}, 0.16)`, + contrastText: `rgba(${this.blackBase}, 1)`, + text: this.text.primary, + border: this.border.strong, + }; + + info = { + main: palette.blueLightMain, + text: palette.blueLightText, + }; + + error = { + main: palette.redLightMain, + text: palette.redLightText, + border: palette.redLightText, + }; + + success = { + main: palette.greenLightMain, + text: palette.greenLightText, + }; + + warning = { + main: palette.orangeLightMain, + text: palette.orangeLightText, + }; + + background = { + canvas: palette.gray90, + primary: palette.white, + secondary: palette.gray100, + }; + + action = { + hover: `rgba(${this.blackBase}, 0.04)`, + selected: `rgba(${this.blackBase}, 0.08)`, + hoverOpacity: 0.08, + focus: `rgba(${this.blackBase}, 0.12)`, + disabledBackground: `rgba(${this.blackBase}, 0.07)`, + disabledText: this.text.disabled, + disabledOpacity: 0.38, + }; + + gradients = { + brandHorizontal: 'linear-gradient(90deg, #FF8833 0%, #F53E4C 100%);', + brandVertical: 'linear-gradient(0.01deg, #F53E4C -31.2%, #FF8833 113.07%);', + }; + + contrastThreshold = 3; + hoverFactor = 0.03; + tonalOffset = 0.2; +} + +export function createColors(colors: ThemeColorsInput): ThemeColors { + const dark = new DarkColors(); + const light = new LightColors(); + const base = (colors.mode ?? 'dark') === 'dark' ? dark : light; + const { + primary = base.primary, + secondary = base.secondary, + info = base.info, + warning = base.warning, + success = base.success, + error = base.error, + tonalOffset = base.tonalOffset, + hoverFactor = base.hoverFactor, + contrastThreshold = base.contrastThreshold, + ...other + } = colors; + + function getContrastText(background: string) { + const contrastText = + getContrastRatio(background, dark.text.maxContrast) >= contrastThreshold + ? dark.text.maxContrast + : light.text.maxContrast; + // todo, need color framework + return contrastText; + } + + const getRichColor = ({ color, name }: GetRichColorProps): ThemeRichColor => { + color = { ...color, name }; + if (!color.main) { + throw new Error(`Missing main color for ${name}`); + } + if (!color.text) { + color.text = color.main; + } + if (!color.border) { + color.border = color.text; + } + if (!color.shade) { + color.shade = base.mode === 'light' ? darken(color.main, tonalOffset) : lighten(color.main, tonalOffset); + } + if (!color.transparent) { + color.transparent = base.mode === 'light' ? alpha(color.main, 0.08) : alpha(color.main, 0.15); + } + if (!color.contrastText) { + color.contrastText = getContrastText(color.main); + } + return color as ThemeRichColor; + }; + + return merge( + { + ...base, + primary: getRichColor({ color: primary, name: 'primary' }), + secondary: getRichColor({ color: secondary, name: 'secondary' }), + info: getRichColor({ color: info, name: 'info' }), + error: getRichColor({ color: error, name: 'error' }), + success: getRichColor({ color: success, name: 'success' }), + warning: getRichColor({ color: warning, name: 'warning' }), + getContrastText, + emphasize: (color: string, factor?: number) => { + return emphasize(color, factor ?? hoverFactor); + }, + }, + other + ); +} + +interface GetRichColorProps { + color: Partial; + name: string; +} diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts new file mode 100644 index 0000000..b9a7c60 --- /dev/null +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -0,0 +1,86 @@ +import { ThemeColors } from './createColors'; +import { ThemeShadows } from './createShadows'; + +/** @beta */ +export interface ThemeComponents { + /** Applies to normal buttons, inputs, radio buttons, etc */ + height: { + sm: number; + md: number; + lg: number; + }; + input: { + background: string; + borderColor: string; + borderHover: string; + text: string; + }; + tooltip: { + text: string; + background: string; + }; + panel: { + padding: number; + headerHeight: number; + borderColor: string; + boxShadow: string; + background: string; + }; + dropdown: { + background: string; + }; + overlay: { + background: string; + }; + dashboard: { + background: string; + padding: number; + }; + sidemenu: { + width: number; + }; +} + +export function createComponents(colors: ThemeColors, shadows: ThemeShadows): ThemeComponents { + const panel = { + padding: 1, + headerHeight: 4, + background: colors.background.primary, + borderColor: colors.border.weak, + boxShadow: 'none', + }; + + const input = { + borderColor: colors.border.medium, + borderHover: colors.border.strong, + text: colors.text.primary, + background: colors.mode === 'dark' ? colors.background.canvas : colors.background.primary, + }; + + return { + height: { + sm: 3, + md: 4, + lg: 6, + }, + input, + panel, + dropdown: { + background: input.background, + }, + tooltip: { + background: colors.mode === 'light' ? '#555' : colors.background.secondary, + text: colors.mode === 'light' ? '#FFF' : colors.text.primary, + }, + dashboard: { + background: colors.background.canvas, + padding: 1, + }, + overlay: { + background: colors.mode === 'dark' ? 'rgba(0, 0, 0, 0.45)' : 'rgba(208, 209, 211, 0.24)', + }, + sidemenu: { + width: 60, + }, + }; +} diff --git a/packages/grafana-data/src/themes/createShadows.ts b/packages/grafana-data/src/themes/createShadows.ts new file mode 100644 index 0000000..bb6dd49 --- /dev/null +++ b/packages/grafana-data/src/themes/createShadows.ts @@ -0,0 +1,25 @@ +import { ThemeColors } from './createColors'; + +/** @beta */ +export interface ThemeShadows { + z1: string; + z2: string; + z3: string; +} + +/** @alpha */ +export function createShadows(colors: ThemeColors): ThemeShadows { + if (colors.mode === 'dark') { + return { + z1: '0px 1px 2px rgba(24, 26, 27, 0.75)', + z2: '0px 4px 8px rgba(24, 26, 27, 0.75)', + z3: '0px 10px 20px rgb(20,20,20)', + }; + } + + return { + z1: '0px 1px 2px rgba(24, 26, 27, 0.2)', + z2: '0px 4px 8px rgba(24, 26, 27, 0.2)', + z3: '0px 13px 20px 1px rgba(24, 26, 27, 0.18)', + }; +} diff --git a/packages/grafana-data/src/themes/createShape.ts b/packages/grafana-data/src/themes/createShape.ts new file mode 100644 index 0000000..686e91b --- /dev/null +++ b/packages/grafana-data/src/themes/createShape.ts @@ -0,0 +1,22 @@ +/** @beta */ +export interface ThemeShape { + borderRadius: (amount?: number) => string; +} + +/** @internal */ +export interface ThemeShapeInput { + borderRadius?: number; +} + +export function createShape(options: ThemeShapeInput): ThemeShape { + const baseBorderRadius = options.borderRadius ?? 2; + + const borderRadius = (amount?: number) => { + const value = (amount ?? 1) * baseBorderRadius; + return `${value}px`; + }; + + return { + borderRadius, + }; +} diff --git a/packages/grafana-data/src/themes/createSpacing.test.ts b/packages/grafana-data/src/themes/createSpacing.test.ts new file mode 100644 index 0000000..83fb4bc --- /dev/null +++ b/packages/grafana-data/src/themes/createSpacing.test.ts @@ -0,0 +1,13 @@ +import { createSpacing } from './createSpacing'; + +describe('createSpacing', () => { + it('Spacing function should handle 0-4 arguments', () => { + const spacing = createSpacing(); + expect(spacing()).toBe('8px'); + expect(spacing(1)).toBe('8px'); + expect(spacing(2)).toBe('16px'); + expect(spacing(1, 2)).toBe('8px 16px'); + expect(spacing(1, 2, 3)).toBe('8px 16px 24px'); + expect(spacing(1, 2, 3, 4)).toBe('8px 16px 24px 32px'); + }); +}); diff --git a/packages/grafana-data/src/themes/createSpacing.ts b/packages/grafana-data/src/themes/createSpacing.ts new file mode 100644 index 0000000..9953c91 --- /dev/null +++ b/packages/grafana-data/src/themes/createSpacing.ts @@ -0,0 +1,71 @@ +// Code based on Material UI +// The MIT License (MIT) +// Copyright (c) 2014 Call-Em-All + +/** @internal */ +export type ThemeSpacingOptions = { + gridSize?: number; +}; + +/** @internal */ +export type ThemeSpacingArgument = number | string; + +/** + * @beta + * The different signatures imply different meaning for their arguments that can't be expressed structurally. + * We express the difference with variable names. + * tslint:disable:unified-signatures */ +export interface ThemeSpacing { + (): string; + (value: number): string; + (topBottom: ThemeSpacingArgument, rightLeft: ThemeSpacingArgument): string; + (top: ThemeSpacingArgument, rightLeft: ThemeSpacingArgument, bottom: ThemeSpacingArgument): string; + ( + top: ThemeSpacingArgument, + right: ThemeSpacingArgument, + bottom: ThemeSpacingArgument, + left: ThemeSpacingArgument + ): string; + gridSize: number; +} + +/** @internal */ +export function createSpacing(options: ThemeSpacingOptions = {}): ThemeSpacing { + const { gridSize = 8 } = options; + + const transform = (value: ThemeSpacingArgument) => { + if (typeof value === 'string') { + return value; + } + + if (process.env.NODE_ENV !== 'production') { + if (typeof value !== 'number') { + console.error(`Expected spacing argument to be a number or a string, got ${value}.`); + } + } + return value * gridSize; + }; + + const spacing = (...args: Array): string => { + if (process.env.NODE_ENV !== 'production') { + if (!(args.length <= 4)) { + console.error(`Too many arguments provided, expected between 0 and 4, got ${args.length}`); + } + } + + if (args.length === 0) { + args[0] = 1; + } + + return args + .map((argument) => { + const output = transform(argument); + return typeof output === 'number' ? `${output}px` : output; + }) + .join(' '); + }; + + spacing.gridSize = gridSize; + + return spacing; +} diff --git a/packages/grafana-data/src/themes/createTheme.test.ts b/packages/grafana-data/src/themes/createTheme.test.ts new file mode 100644 index 0000000..a27fc62 --- /dev/null +++ b/packages/grafana-data/src/themes/createTheme.test.ts @@ -0,0 +1,26 @@ +import { createTheme } from './createTheme'; + +describe('createTheme', () => { + it('create custom theme', () => { + const custom = createTheme({ + colors: { + mode: 'dark', + primary: { + main: 'rgb(240,0,0)', + }, + background: { + canvas: '#123', + }, + }, + }); + + expect(custom.colors.primary.main).toBe('rgb(240,0,0)'); + expect(custom.colors.primary.shade).toBe('rgb(242, 38, 38)'); + expect(custom.colors.background.canvas).toBe('#123'); + }); + + it('create default theme', () => { + const theme = createTheme(); + expect(theme.colors.mode).toBe('dark'); + }); +}); diff --git a/packages/grafana-data/src/themes/createTheme.ts b/packages/grafana-data/src/themes/createTheme.ts new file mode 100644 index 0000000..7b528b1 --- /dev/null +++ b/packages/grafana-data/src/themes/createTheme.ts @@ -0,0 +1,65 @@ +import { createBreakpoints } from './breakpoints'; +import { createComponents } from './createComponents'; +import { createColors, ThemeColorsInput } from './createColors'; +import { createShadows } from './createShadows'; +import { createShape, ThemeShapeInput } from './createShape'; +import { createSpacing, ThemeSpacingOptions } from './createSpacing'; +import { createTransitions } from './createTransitions'; +import { createTypography, ThemeTypographyInput } from './createTypography'; +import { createV1Theme } from './createV1Theme'; +import { GrafanaTheme2 } from './types'; +import { zIndex } from './zIndex'; +import { createVisualizationColors } from './createVisualizationColors'; + +/** @internal */ +export interface NewThemeOptions { + name?: string; + colors?: ThemeColorsInput; + spacing?: ThemeSpacingOptions; + shape?: ThemeShapeInput; + typography?: ThemeTypographyInput; +} + +/** @internal */ +export function createTheme(options: NewThemeOptions = {}): GrafanaTheme2 { + const { + name = 'Dark', + colors: colorsInput = {}, + spacing: spacingInput = {}, + shape: shapeInput = {}, + typography: typographyInput = {}, + } = options; + + const colors = createColors(colorsInput); + const breakpoints = createBreakpoints(); + const spacing = createSpacing(spacingInput); + const shape = createShape(shapeInput); + const typography = createTypography(colors, typographyInput); + const shadows = createShadows(colors); + const transitions = createTransitions(); + const components = createComponents(colors, shadows); + const visualization = createVisualizationColors(colors); + + const theme = { + name, + isDark: colors.mode === 'dark', + isLight: colors.mode === 'light', + colors, + breakpoints, + spacing, + shape, + components, + typography, + shadows, + transitions, + visualization, + zIndex: { + ...zIndex, + }, + }; + + return { + ...theme, + v1: createV1Theme(theme), + }; +} diff --git a/packages/grafana-data/src/themes/createTransitions.test.ts b/packages/grafana-data/src/themes/createTransitions.test.ts new file mode 100644 index 0000000..99b66d5 --- /dev/null +++ b/packages/grafana-data/src/themes/createTransitions.test.ts @@ -0,0 +1,84 @@ +import { createTransitions } from './createTransitions'; + +describe('transitions', () => { + const { duration, easing, getAutoHeightDuration, create } = createTransitions(); + + describe('create() function', () => { + it('should create default transition without arguments', () => { + const transition = create(); + expect(transition).toEqual(`all ${duration.standard}ms ${easing.easeInOut} 0ms`); + }); + + it('should take string props as a first argument', () => { + const transition = create('color'); + expect(transition).toEqual(`color ${duration.standard}ms ${easing.easeInOut} 0ms`); + }); + + it('should also take array of props as first argument', () => { + const options = { delay: 20 }; + const multiple = create(['color', 'size'], options); + const single1 = create('color', options); + const single2 = create('size', options); + const expected = `${single1},${single2}`; + expect(multiple).toEqual(expected); + }); + + it('should optionally accept number "duration" option in second argument', () => { + const transition = create('font', { duration: 500 }); + expect(transition).toEqual(`font 500ms ${easing.easeInOut} 0ms`); + }); + + it('should optionally accept string "duration" option in second argument', () => { + const transition = create('font', { duration: '500ms' }); + expect(transition).toEqual(`font 500ms ${easing.easeInOut} 0ms`); + }); + + it('should round decimal digits of "duration" prop to whole numbers', () => { + const transition = create('font', { duration: 12.125 }); + expect(transition).toEqual(`font 12ms ${easing.easeInOut} 0ms`); + }); + + it('should optionally accept string "easing" option in second argument', () => { + const transition = create('transform', { easing: easing.sharp }); + expect(transition).toEqual(`transform ${duration.standard}ms ${easing.sharp} 0ms`); + }); + + it('should optionally accept number "delay" option in second argument', () => { + const transition = create('size', { delay: 150 }); + expect(transition).toEqual(`size ${duration.standard}ms ${easing.easeInOut} 150ms`); + }); + + it('should optionally accept string "delay" option in second argument', () => { + const transition = create('size', { delay: '150ms' }); + expect(transition).toEqual(`size ${duration.standard}ms ${easing.easeInOut} 150ms`); + }); + + it('should round decimal digits of "delay" prop to whole numbers', () => { + const transition = create('size', { delay: 1.547 }); + expect(transition).toEqual(`size ${duration.standard}ms ${easing.easeInOut} 2ms`); + }); + + it('should return NaN when passed a negative number', () => { + const zeroHeightDurationNegativeOne = getAutoHeightDuration(-1); + // eslint-disable-next-line no-restricted-globals + expect(isNaN(zeroHeightDurationNegativeOne)).toEqual(true); + const zeroHeightDurationSmallNegative = getAutoHeightDuration(-0.000001); + // eslint-disable-next-line no-restricted-globals + expect(isNaN(zeroHeightDurationSmallNegative)).toEqual(true); + const zeroHeightDurationBigNegative = getAutoHeightDuration(-100000); + // eslint-disable-next-line no-restricted-globals + expect(isNaN(zeroHeightDurationBigNegative)).toEqual(true); + }); + + it('should return values for pre-calculated positive examples', () => { + let zeroHeightDuration = getAutoHeightDuration(14); + expect(zeroHeightDuration).toEqual(159); + zeroHeightDuration = getAutoHeightDuration(100); + expect(zeroHeightDuration).toEqual(239); + zeroHeightDuration = getAutoHeightDuration(0.0001); + expect(zeroHeightDuration).toEqual(46); + zeroHeightDuration = getAutoHeightDuration(100000); + expect(zeroHeightDuration).toEqual(6685); + }); + }); +}); diff --git a/packages/grafana-data/src/themes/createTransitions.ts b/packages/grafana-data/src/themes/createTransitions.ts new file mode 100644 index 0000000..d15b821 --- /dev/null +++ b/packages/grafana-data/src/themes/createTransitions.ts @@ -0,0 +1,87 @@ +// Code based on Material UI +// The MIT License (MIT) +// Copyright (c) 2014 Call-Em-All + +// Follow https://material.google.com/motion/duration-easing.html#duration-easing-natural-easing-curves +// to learn the context in which each easing should be used. +const easing = { + // This is the most common easing curve. + easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)', + // Objects enter the screen at full velocity from off-screen and + // slowly decelerate to a resting point. + easeOut: 'cubic-bezier(0.0, 0, 0.2, 1)', + // Objects leave the screen at full velocity. They do not decelerate when off-screen. + easeIn: 'cubic-bezier(0.4, 0, 1, 1)', + // The sharp curve is used by objects that may return to the screen at any time. + sharp: 'cubic-bezier(0.4, 0, 0.6, 1)', +}; + +// Follow https://material.io/guidelines/motion/duration-easing.html#duration-easing-common-durations +// to learn when use what timing +const duration = { + shortest: 150, + shorter: 200, + short: 250, + // most basic recommended timing + standard: 300, + // this is to be used in complex animations + complex: 375, + // recommended when something is entering screen + enteringScreen: 225, + // recommended when something is leaving screen + leavingScreen: 195, +}; + +/** @alpha */ +export interface CreateTransitionOptions { + duration?: number | string; + easing?: string; + delay?: number | string; +} + +/** @alpha */ +export function create(props: string | string[] = ['all'], options: CreateTransitionOptions = {}) { + const { duration: durationOption = duration.standard, easing: easingOption = easing.easeInOut, delay = 0 } = options; + + return (Array.isArray(props) ? props : [props]) + .map( + (animatedProp) => + `${animatedProp} ${ + typeof durationOption === 'string' ? durationOption : formatMs(durationOption) + } ${easingOption} ${typeof delay === 'string' ? delay : formatMs(delay)}` + ) + .join(','); +} + +export function getAutoHeightDuration(height: number) { + if (!height) { + return 0; + } + + const constant = height / 36; + + // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 + return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); +} + +function formatMs(milliseconds: number) { + return `${Math.round(milliseconds)}ms`; +} + +/** @alpha */ +export interface ThemeTransitions { + create: typeof create; + duration: typeof duration; + easing: typeof easing; + getAutoHeightDuration: typeof getAutoHeightDuration; +} + +/** @internal */ +export function createTransitions(): ThemeTransitions { + return { + create, + duration, + easing, + getAutoHeightDuration, + }; +} diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts new file mode 100644 index 0000000..9d9776a --- /dev/null +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -0,0 +1,147 @@ +// Code based on Material UI +// The MIT License (MIT) +// Copyright (c) 2014 Call-Em-All + +import { ThemeColors } from './createColors'; + +/** @beta */ +export interface ThemeTypography { + fontFamily: string; + fontFamilyMonospace: string; + fontSize: number; + fontWeightLight: number; + fontWeightRegular: number; + fontWeightMedium: number; + fontWeightBold: number; + + // The font-size on the html element. + htmlFontSize?: number; + + h1: ThemeTypographyVariant; + h2: ThemeTypographyVariant; + h3: ThemeTypographyVariant; + h4: ThemeTypographyVariant; + h5: ThemeTypographyVariant; + h6: ThemeTypographyVariant; + + body: ThemeTypographyVariant; + bodySmall: ThemeTypographyVariant; + + /** + * @deprecated + * from legacy old theme + * */ + size: { + base: string; + xs: string; + sm: string; + md: string; + lg: string; + }; + + pxToRem: (px: number) => string; +} + +export interface ThemeTypographyVariant { + fontSize: string; + fontWeight: number; + lineHeight: number; + fontFamily: string; + letterSpacing?: string; +} + +export interface ThemeTypographyInput { + fontFamily?: string; + fontFamilyMonospace?: string; + fontSize?: number; + fontWeightLight?: number; + fontWeightRegular?: number; + fontWeightMedium?: number; + fontWeightBold?: number; + // hat's the font-size on the html element. + // 16px is the default font-size used by browsers. + htmlFontSize?: number; +} + +const defaultFontFamily = '"Roboto", "Helvetica", "Arial", sans-serif'; +const defaultFontFamilyMonospace = "'Roboto Mono', monospace"; + +export function createTypography(colors: ThemeColors, typographyInput: ThemeTypographyInput = {}): ThemeTypography { + const { + fontFamily = defaultFontFamily, + fontFamilyMonospace = defaultFontFamilyMonospace, + // The default font size of the Material Specification. + fontSize = 14, // px + fontWeightLight = 300, + fontWeightRegular = 400, + fontWeightMedium = 500, + fontWeightBold = 500, + // Tell Grafana-UI what's the font-size on the html element. + // 16px is the default font-size used by browsers. + htmlFontSize = 14, + } = typographyInput; + + if (process.env.NODE_ENV !== 'production') { + if (typeof fontSize !== 'number') { + console.error('Grafana-UI: `fontSize` is required to be a number.'); + } + + if (typeof htmlFontSize !== 'number') { + console.error('Grafana-UI: `htmlFontSize` is required to be a number.'); + } + } + + const coef = fontSize / 14; + const pxToRem = (size: number) => `${(size / htmlFontSize) * coef}rem`; + const buildVariant = ( + fontWeight: number, + size: number, + lineHeight: number, + letterSpacing: number, + casing?: object + ): ThemeTypographyVariant => ({ + fontFamily, + fontWeight, + fontSize: pxToRem(size), + lineHeight, + ...(fontFamily === defaultFontFamily ? { letterSpacing: `${round(letterSpacing / size)}em` } : {}), + ...casing, + }); + + const variants = { + h1: buildVariant(fontWeightLight, 28, 1.167, -0.25), + h2: buildVariant(fontWeightLight, 24, 1.2, 0), + h3: buildVariant(fontWeightRegular, 21, 1.167, 0), + h4: buildVariant(fontWeightRegular, 18, 1.235, 0.25), + h5: buildVariant(fontWeightRegular, 16, 1.334, 0), + h6: buildVariant(fontWeightMedium, 14, 1.6, 0.15), + body: buildVariant(fontWeightRegular, 14, 1.5, 0.15), + bodySmall: buildVariant(fontWeightRegular, 12, 1.5, 0.15), + }; + + const size = { + base: '14px', + xs: '10px', + sm: '12px', + md: '14px', + lg: '18px', + }; + + return { + htmlFontSize, + pxToRem, + fontFamily, + fontFamilyMonospace, + fontSize, + fontWeightLight, + fontWeightRegular, + fontWeightMedium, + fontWeightBold, + size, + ...variants, + }; +} + +function round(value: number) { + return Math.round(value * 1e5) / 1e5; +} diff --git a/packages/grafana-data/src/themes/createV1Theme.ts b/packages/grafana-data/src/themes/createV1Theme.ts new file mode 100644 index 0000000..de9d381 --- /dev/null +++ b/packages/grafana-data/src/themes/createV1Theme.ts @@ -0,0 +1,259 @@ +import { GrafanaTheme, GrafanaThemeCommons, GrafanaThemeType } from '../types'; +import { GrafanaTheme2 } from './types'; + +export function createV1Theme(theme: Omit): GrafanaTheme { + const oldCommon: GrafanaThemeCommons = { + name: 'Grafana Default', + typography: { + fontFamily: { + sansSerif: theme.typography.fontFamily, + monospace: theme.typography.fontFamilyMonospace, + }, + size: { + base: `${theme.typography.fontSize}px`, + xs: theme.typography.size.xs, + sm: theme.typography.size.sm, + md: theme.typography.size.md, + lg: theme.typography.size.lg, + }, + heading: { + h1: theme.typography.h1.fontSize, + h2: theme.typography.h2.fontSize, + h3: theme.typography.h3.fontSize, + h4: theme.typography.h4.fontSize, + h5: theme.typography.h5.fontSize, + h6: theme.typography.h6.fontSize, + }, + weight: { + light: theme.typography.fontWeightLight, + regular: theme.typography.fontWeightRegular, + semibold: theme.typography.fontWeightMedium, + bold: theme.typography.fontWeightBold, + }, + lineHeight: { + xs: theme.typography.bodySmall.lineHeight, + sm: theme.typography.bodySmall.lineHeight, + md: theme.typography.body.lineHeight, + lg: theme.typography.h2.lineHeight, + }, + link: { + decoration: 'none', + hoverDecoration: 'none', + }, + }, + breakpoints: { + xs: `${theme.breakpoints.values.xs}px`, + sm: `${theme.breakpoints.values.sm}px`, + md: `${theme.breakpoints.values.md}px`, + lg: `${theme.breakpoints.values.lg}px`, + xl: `${theme.breakpoints.values.xl}px`, + xxl: `${theme.breakpoints.values.xxl}px`, + }, + spacing: { + base: theme.spacing.gridSize, + insetSquishMd: theme.spacing(0.5, 1), + d: theme.spacing(2), + xxs: theme.spacing(0.25), + xs: theme.spacing(0.5), + sm: theme.spacing(1), + md: theme.spacing(2), + lg: theme.spacing(3), + xl: theme.spacing(4), + gutter: theme.spacing(4), + + // Next-gen forms spacing variables + // TODO: Move variables definition to respective components when implementing + formSpacingBase: theme.spacing.gridSize, + formMargin: `${theme.spacing.gridSize * 4}px`, + formFieldsetMargin: `${theme.spacing.gridSize * 2}px`, + formInputHeight: theme.spacing.gridSize * 4, + formButtonHeight: theme.spacing.gridSize * 4, + formInputPaddingHorizontal: `${theme.spacing.gridSize}px`, + + // Used for icons do define spacing between icon and input field + // Applied on the right(prefix) or left(suffix) + formInputAffixPaddingHorizontal: `${theme.spacing.gridSize / 2}px`, + + formInputMargin: `${theme.spacing.gridSize * 2}px`, + formLabelPadding: '0 0 0 2px', + formLabelMargin: `0 0 ${theme.spacing.gridSize / 2 + 'px'} 0`, + formValidationMessagePadding: '4px 8px', + formValidationMessageMargin: '4px 0 0 0', + inlineFormMargin: '4px', + }, + border: { + radius: { + sm: theme.shape.borderRadius(1), + md: theme.shape.borderRadius(2), + lg: theme.shape.borderRadius(3), + }, + width: { + sm: '1px', + }, + }, + height: { + sm: theme.spacing.gridSize * theme.components.height.sm, + md: theme.spacing.gridSize * theme.components.height.md, + lg: theme.spacing.gridSize * theme.components.height.lg, + }, + panelPadding: theme.components.panel.padding * theme.spacing.gridSize, + panelHeaderHeight: theme.spacing.gridSize * theme.components.panel.headerHeight, + zIndex: { + navbarFixed: theme.zIndex.navbarFixed, + sidemenu: theme.zIndex.sidemenu, + dropdown: theme.zIndex.dropdown, + typeahead: theme.zIndex.typeahead, + tooltip: theme.zIndex.tooltip, + modalBackdrop: theme.zIndex.modalBackdrop, + modal: theme.zIndex.modal, + }, + }; + + const basicColors = { + ...commonColorsPalette, + black: '#000000', + white: '#ffffff', + dark1: '#141414', + dark2: '#161719', + dark3: '#1f1f20', + dark4: '#212124', + dark5: '#222426', + dark6: '#262628', + dark7: '#292a2d', + dark8: '#2f2f32', + dark9: '#343436', + dark10: '#424345', + gray1: '#555555', + gray2: '#8e8e8e', + gray3: '#b3b3b3', + gray4: '#d8d9da', + gray5: '#ececec', + gray6: '#f4f5f8', // not used in dark theme + gray7: '#fbfbfb', // not used in dark theme + redBase: '#e02f44', + redShade: '#c4162a', + greenBase: '#299c46', + greenShade: '#23843b', + red: '#d44a3a', + yellow: '#ecbb13', + purple: '#9933cc', + variable: '#32d1df', + orange: '#eb7b18', + orangeDark: '#ff780a', + }; + + const backgrounds = { + bg1: theme.colors.background.primary, + bg2: theme.colors.background.secondary, + bg3: theme.colors.action.hover, + dashboardBg: theme.colors.background.canvas, + bgBlue1: theme.colors.primary.main, + bgBlue2: theme.colors.primary.shade, + }; + + const borders = { + border1: theme.colors.border.weak, + border2: theme.colors.border.medium, + border3: theme.colors.border.strong, + }; + + const textColors = { + textStrong: theme.colors.text.maxContrast, + textHeading: theme.colors.text.primary, + text: theme.colors.text.primary, + textSemiWeak: theme.colors.text.secondary, + textWeak: theme.colors.text.secondary, + textFaint: theme.colors.text.disabled, + textBlue: theme.colors.primary.text, + }; + + const form = { + // Next-gen forms functional colors + formLabel: theme.colors.text.primary, + formDescription: theme.colors.text.secondary, + formInputBg: theme.components.input.background, + formInputBgDisabled: theme.colors.action.disabledBackground, + formInputBorder: theme.components.input.borderColor, + formInputBorderHover: theme.components.input.borderHover, + formInputBorderActive: theme.colors.primary.border, + formInputBorderInvalid: theme.colors.error.border, + formInputPlaceholderText: theme.colors.text.disabled, + formInputText: theme.components.input.text, + formInputDisabledText: theme.colors.action.disabledText, + formFocusOutline: theme.colors.primary.main, + formValidationMessageText: theme.colors.error.contrastText, + formValidationMessageBg: theme.colors.error.main, + }; + + return { + ...oldCommon, + type: theme.colors.mode === 'dark' ? GrafanaThemeType.Dark : GrafanaThemeType.Light, + isDark: theme.isDark, + isLight: theme.isLight, + name: theme.name, + palette: { + ...basicColors, + brandPrimary: basicColors.orange, + brandSuccess: theme.colors.success.main, + brandWarning: theme.colors.warning.main, + brandDanger: theme.colors.error.main, + queryRed: theme.colors.error.text, + queryGreen: theme.colors.success.text, + queryPurple: '#fe85fc', + queryOrange: basicColors.orange, + online: theme.colors.success.main, + warn: theme.colors.success.main, + critical: theme.colors.success.main, + }, + colors: { + ...backgrounds, + ...borders, + ...form, + ...textColors, + + bodyBg: theme.colors.background.canvas, + panelBg: theme.components.panel.background, + panelBorder: theme.components.panel.borderColor, + pageHeaderBg: theme.colors.background.canvas, + pageHeaderBorder: theme.colors.background.canvas, + + dropdownBg: form.formInputBg, + dropdownShadow: basicColors.black, + dropdownOptionHoverBg: backgrounds.bg2, + + link: theme.colors.text.primary, + linkDisabled: theme.colors.text.disabled, + linkHover: theme.colors.text.maxContrast, + linkExternal: theme.colors.text.link, + }, + shadows: { + listItem: 'none', + }, + visualization: theme.visualization, + }; +} + +const commonColorsPalette = { + // New greys palette used by next-gen form elements + gray98: '#f7f8fa', + gray97: '#f1f5f9', + gray95: '#e9edf2', + gray90: '#dce1e6', + gray85: '#c7d0d9', + gray70: '#9fa7b3', + gray60: '#7b8087', + gray33: '#464c54', + gray25: '#2c3235', + gray15: '#202226', + gray10: '#141619', + gray05: '#0b0c0e', + + // New blues palette used by next-gen form elements + blue95: '#5794f2', // blue95 + blue85: '#33a2e5', // blueText + blue80: '#3274d9', // blue80 + blue77: '#1f60c4', // blue77 + + // New reds palette used by next-gen form elements + red88: '#e02f44', +}; diff --git a/packages/grafana-data/src/themes/createVisualizationColors.test.ts b/packages/grafana-data/src/themes/createVisualizationColors.test.ts new file mode 100644 index 0000000..129e4bf --- /dev/null +++ b/packages/grafana-data/src/themes/createVisualizationColors.test.ts @@ -0,0 +1,32 @@ +import { createColors } from './createColors'; +import { createVisualizationColors } from './createVisualizationColors'; + +describe('createVizColors', () => { + const darkThemeColors = createColors({}); + const vizColors = createVisualizationColors(darkThemeColors); + + it('Can map named colors to real color', () => { + expect(vizColors.getColorByName('green')).toBe('#73BF69'); + }); + + it('Can map named colors using old aliases to real color', () => { + expect(vizColors.getColorByName('dark-green')).toBe('#37872D'); + }); + + it('Can get color from palette', () => { + expect(vizColors.palette[0]).not.toBeUndefined(); + }); + + it('returns color if specified as hex or rgb/a', () => { + expect(vizColors.getColorByName('#ff0000')).toBe('#ff0000'); + expect(vizColors.getColorByName('#ff0000')).toBe('#ff0000'); + expect(vizColors.getColorByName('#FF0000')).toBe('#FF0000'); + expect(vizColors.getColorByName('#CCC')).toBe('#CCC'); + expect(vizColors.getColorByName('rgb(0,0,0)')).toBe('rgb(0,0,0)'); + expect(vizColors.getColorByName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)'); + }); + + it('returns hex for named color that is not a part of named colors palette', () => { + expect(vizColors.getColorByName('lime')).toBe('#00ff00'); + }); +}); diff --git a/packages/grafana-data/src/themes/createVisualizationColors.ts b/packages/grafana-data/src/themes/createVisualizationColors.ts new file mode 100644 index 0000000..3a9a442 --- /dev/null +++ b/packages/grafana-data/src/themes/createVisualizationColors.ts @@ -0,0 +1,521 @@ +import { FALLBACK_COLOR } from '../types'; +import { ThemeColors } from './createColors'; + +/** + * @alpha + */ +export interface ThemeVisualizationColors { + /** Only for internal use by color schemes */ + palette: string[]; + /** Lookup the real color given the name */ + getColorByName: (color: string) => string; + /** Colors organized by hue */ + hues: ThemeVizHue[]; +} + +/** + * @alpha + */ +export interface ThemeVizColor { + color: string; + name: string; + aliases?: string[]; + primary?: boolean; +} + +/** + * @alpha + */ +export interface ThemeVizHue { + name: string; + shades: ThemeVizColor[]; +} + +/** + * @internal + */ +export function createVisualizationColors(colors: ThemeColors): ThemeVisualizationColors { + let hues: ThemeVizHue[] = []; + + if (colors.mode === 'dark') { + hues = getDarkHues(); + } else if (colors.mode === 'light') { + hues = getLightHues(); + } + + const byNameIndex: Record = {}; + + for (const hue of hues) { + for (const shade of hue.shades) { + byNameIndex[shade.name] = shade.color; + if (shade.aliases) { + for (const alias of shade.aliases) { + byNameIndex[alias] = shade.color; + } + } + } + } + + // special colors + byNameIndex['transparent'] = 'rgba(0,0,0,0)'; + byNameIndex['panel-bg'] = colors.background.primary; + byNameIndex['text'] = colors.text.primary; + + const getColorByName = (colorName: string) => { + if (!colorName) { + return FALLBACK_COLOR; + } + + const realColor = byNameIndex[colorName]; + if (realColor) { + return realColor; + } + + if (colorName[0] === '#') { + return colorName; + } + + if (colorName.indexOf('rgb') > -1) { + return colorName; + } + + const nativeColor = nativeColorNames[colorName.toLowerCase()]; + if (nativeColor) { + byNameIndex[colorName] = nativeColor; + return nativeColor; + } + + return colorName; + }; + + const palette = getClassicPalette(); + + return { + hues, + palette, + getColorByName, + }; +} + +function getDarkHues(): ThemeVizHue[] { + return [ + { + name: 'red', + shades: [ + { color: '#FFA6B0', name: 'super-light-red' }, + { color: '#FF7383', name: 'light-red' }, + { color: '#F2495C', name: 'red', primary: true }, + { color: '#E02F44', name: 'semi-dark-red' }, + { color: '#C4162A', name: 'dark-red' }, + ], + }, + { + name: 'orange', + shades: [ + { color: '#FFCB7D', name: 'super-light-orange', aliases: [] }, + { color: '#FFB357', name: 'light-orange', aliases: [] }, + { color: '#FF9830', name: 'orange', aliases: [], primary: true }, + { color: '#FF780A', name: 'semi-dark-orange', aliases: [] }, + { color: '#FA6400', name: 'dark-orange', aliases: [] }, + ], + }, + { + name: 'yellow', + shades: [ + { color: '#FFF899', name: 'super-light-yellow', aliases: [] }, + { color: '#FFEE52', name: 'light-yellow', aliases: [] }, + { color: '#FADE2A', name: 'yellow', aliases: [], primary: true }, + { color: '#F2CC0C', name: 'semi-dark-yellow', aliases: [] }, + { color: '#E0B400', name: 'dark-yellow', aliases: [] }, + ], + }, + { + name: 'green', + shades: [ + { color: '#C8F2C2', name: 'super-light-green', aliases: [] }, + { color: '#96D98D', name: 'light-green', aliases: [] }, + { color: '#73BF69', name: 'green', aliases: [], primary: true }, + { color: '#56A64B', name: 'semi-dark-green', aliases: [] }, + { color: '#37872D', name: 'dark-green', aliases: [] }, + ], + }, + { + name: 'blue', + shades: [ + { color: '#C0D8FF', name: 'super-light-blue', aliases: [] }, + { color: '#8AB8FF', name: 'light-blue', aliases: [] }, + { color: '#5794F2', name: 'blue', aliases: [], primary: true }, + { color: '#3274D9', name: 'semi-dark-blue', aliases: [] }, + { color: '#1F60C4', name: 'dark-blue', aliases: [] }, + ], + }, + { + name: 'purple', + shades: [ + { color: '#DEB6F2', name: 'super-light-purple', aliases: [] }, + { color: '#CA95E5', name: 'light-purple', aliases: [] }, + { color: '#B877D9', name: 'purple', aliases: [], primary: true }, + { color: '#A352CC', name: 'semi-dark-purple', aliases: [] }, + { color: '#8F3BB8', name: 'dark-purple', aliases: [] }, + ], + }, + ]; +} + +function getLightHues(): ThemeVizHue[] { + return [ + { + name: 'red', + shades: [ + { color: '#FF7383', name: 'super-light-red' }, + { color: '#F2495C', name: 'light-red' }, + { color: '#E02F44', name: 'red', primary: true }, + { color: '#C4162A', name: 'semi-dark-red' }, + { color: '#AD0317', name: 'dark-red' }, + ], + }, + { + name: 'orange', + shades: [ + { color: '#FFB357', name: 'super-light-orange', aliases: [] }, + { color: '#FF9830', name: 'light-orange', aliases: [] }, + { color: '#FF780A', name: 'orange', aliases: [], primary: true }, + { color: '#FA6400', name: 'semi-dark-orange', aliases: [] }, + { color: '#E55400', name: 'dark-orange', aliases: [] }, + ], + }, + { + name: 'yellow', + shades: [ + { color: '#FFEE52', name: 'super-light-yellow', aliases: [] }, + { color: '#FADE2A', name: 'light-yellow', aliases: [] }, + { color: '#F2CC0C', name: 'yellow', aliases: [], primary: true }, + { color: '#E0B400', name: 'semi-dark-yellow', aliases: [] }, + { color: '#CC9D00', name: 'dark-yellow', aliases: [] }, + ], + }, + { + name: 'green', + shades: [ + { color: '#96D98D', name: 'super-light-green', aliases: [] }, + { color: '#73BF69', name: 'light-green', aliases: [] }, + { color: '#56A64B', name: 'green', aliases: [], primary: true }, + { color: '#37872D', name: 'semi-dark-green', aliases: [] }, + { color: '#19730E', name: 'dark-green', aliases: [] }, + ], + }, + { + name: 'blue', + shades: [ + { color: '#8AB8FF', name: 'super-light-blue', aliases: [] }, + { color: '#5794F2', name: 'light-blue', aliases: [] }, + { color: '#3274D9', name: 'blue', aliases: [], primary: true }, + { color: '#1F60C4', name: 'semi-dark-blue', aliases: [] }, + { color: '#1250B0', name: 'dark-blue', aliases: [] }, + ], + }, + { + name: 'purple', + shades: [ + { color: '#CA95E5', name: 'super-light-purple', aliases: [] }, + { color: '#B877D9', name: 'light-purple', aliases: [] }, + { color: '#A352CC', name: 'purple', aliases: [], primary: true }, + { color: '#8F3BB8', name: 'semi-dark-purple', aliases: [] }, + { color: '#7C2EA3', name: 'dark-purple', aliases: [] }, + ], + }, + ]; +} + +function getClassicPalette() { + // Todo replace these with named colors (as many as possible) + + return [ + 'green', // '#7EB26D', // 0: pale green + 'semi-dark-yellow', // '#EAB839', // 1: mustard + 'light-blue', // #6ED0E0', // 2: light blue + 'semi-dark-orange', // '#EF843C', // 3: orange + 'red', // '#E24D42', // 4: red + 'blue', // #1F78C1', // 5: ocean + 'purple', // '#BA43A9', // 6: purple + '#705DA0', // 7: violet + 'dark-green', // '#508642', // 8: dark green + 'yellow', //'#CCA300', // 9: dark sand + '#447EBC', + '#C15C17', + '#890F02', + '#0A437C', + '#6D1F62', + '#584477', + '#B7DBAB', + '#F4D598', + '#70DBED', + '#F9BA8F', + '#F29191', + '#82B5D8', + '#E5A8E2', + '#AEA2E0', + '#629E51', + '#E5AC0E', + '#64B0C8', + '#E0752D', + '#BF1B00', + '#0A50A1', + '#962D82', + '#614D93', + '#9AC48A', + '#F2C96D', + '#65C5DB', + '#F9934E', + '#EA6460', + '#5195CE', + '#D683CE', + '#806EB7', + '#3F6833', + '#967302', + '#2F575E', + '#99440A', + '#58140C', + '#052B51', + '#511749', + '#3F2B5B', + '#E0F9D7', + '#FCEACA', + '#CFFAFF', + '#F9E2D2', + '#FCE2DE', + '#BADFF4', + '#F9D9F9', + '#DEDAF7', + ]; +} + +// Old hues +// function getDarkHues(): ThemeVizHue[] { +// return [ +// { +// name: 'red', +// shades: [ +// { name: 'red1', color: '#FFC2D4', aliases: ['super-light-red'] }, +// { name: 'red2', color: '#FFA8C2', aliases: ['light-red'] }, +// { name: 'red3', color: '#FF85A9', aliases: ['red'], primary: true }, +// { name: 'red4', color: '#FF5286', aliases: ['semi-dark-red'] }, +// { name: 'red5', color: '#E0226E', aliases: ['dark-red'] }, +// ], +// }, +// { +// name: 'orange', +// shades: [ +// { name: 'orange1', color: '#FFC0AD', aliases: ['super-light-orange'] }, +// { name: 'orange2', color: '#FFA98F', aliases: ['light-orange'] }, +// { name: 'orange3', color: '#FF825C', aliases: ['orange'], primary: true }, +// { name: 'orange4', color: '#FF5F2E', aliases: ['semi-dark-orange'] }, +// { name: 'orange5', color: '#E73903', aliases: ['dark-orange'] }, +// ], +// }, +// { +// name: 'yellow', +// shades: [ +// { name: 'yellow1', color: '#FFE68F', aliases: ['super-light-yellow'] }, +// { name: 'yellow2', color: '#FAD34A', aliases: ['light-yellow'] }, +// { name: 'yellow3', color: '#ECBB09', aliases: ['yellow'], primary: true }, +// { name: 'yellow4', color: '#CFA302', aliases: ['semi-dark-yellow'] }, +// { name: 'yellow5', color: '#AD8800', aliases: ['dark-yellow'] }, +// ], +// }, +// { +// name: 'green', +// shades: [ +// { name: 'green1', color: '#93ECCB', aliases: ['super-light-green'] }, +// { name: 'green2', color: '#65DCB1', aliases: ['light-green'] }, +// { name: 'green3', color: '#2DC88F', aliases: ['green'], primary: true }, +// { name: 'green4', color: '#25A777', aliases: ['semi-dark-green'] }, +// { name: 'green5', color: '#1B855E', aliases: ['dark-green'] }, +// ], +// }, +// { +// name: 'teal', +// shades: [ +// { name: 'teal1', color: '#73E7F7' }, +// { name: 'teal2', color: '#2BD6EE' }, +// { name: 'teal3', color: '#11BDD4', primary: true }, +// { name: 'teal4', color: '#0EA0B4' }, +// { name: 'teal5', color: '#077D8D' }, +// ], +// }, +// { +// name: 'blue', +// shades: [ +// { name: 'blue1', color: '#C2D7FF', aliases: ['super-light-blue'] }, +// { name: 'blue2', color: '#A3C2FF', aliases: ['light-blue'] }, +// { name: 'blue3', color: '#83ACFC', aliases: ['blue'], primary: true }, +// { name: 'blue4', color: '#5D8FEF', aliases: ['semi-dark-blue'] }, +// { name: 'blue5', color: '#3871DC', aliases: ['dark-blue'] }, +// ], +// }, +// { +// name: 'violet', +// shades: [ +// { name: 'violet1', color: '#DACCFF' }, +// { name: 'violet2', color: '#C7B2FF' }, +// { name: 'violet3', color: '#B094FF', primary: true }, +// { name: 'violet4', color: '#9271EF' }, +// { name: 'violet5', color: '#7E63CA' }, +// ], +// }, +// { +// name: 'purple', +// shades: [ +// { name: 'purple1', color: '#FFBDFF', aliases: ['super-light-purple'] }, +// { name: 'purple2', color: '#F5A3F5', aliases: ['light-purple'] }, +// { name: 'purple3', color: '#E48BE4', aliases: ['purple'], primary: true }, +// { name: 'purple4', color: '#CA68CA', aliases: ['semi-dark-purple'] }, +// { name: 'purple5', color: '#B545B5', aliases: ['dark-purple'] }, +// ], +// }, +// ]; +// } + +const nativeColorNames: Record = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgreen: '#006400', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + green: '#008000', + greenyellow: '#adff2f', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + 'indianred ': '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgrey: '#d3d3d3', + lightgreen: '#90ee90', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370d8', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#d87093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + rebeccapurple: '#663399', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32', +}; diff --git a/packages/grafana-data/src/themes/index.ts b/packages/grafana-data/src/themes/index.ts new file mode 100644 index 0000000..17cc7c1 --- /dev/null +++ b/packages/grafana-data/src/themes/index.ts @@ -0,0 +1,15 @@ +export { createTheme } from './createTheme'; +export { ThemeRichColor, GrafanaTheme2 } from './types'; +export { ThemeColors } from './createColors'; +export { ThemeBreakpoints, ThemeBreakpointsKey } from './breakpoints'; +export { ThemeShadows } from './createShadows'; +export { ThemeShape } from './createShape'; +export { ThemeTypography, ThemeTypographyVariant } from './createTypography'; +export { ThemeTransitions } from './createTransitions'; +export { ThemeSpacing } from './createSpacing'; +export { ThemeZIndices } from './zIndex'; +export { ThemeVisualizationColors, ThemeVizColor, ThemeVizHue } from './createVisualizationColors'; + +/** Exporting the module like this to be able to generate docs properly. */ +import * as colorManipulator from './colorManipulator'; +export { colorManipulator }; diff --git a/packages/grafana-data/src/themes/palette.ts b/packages/grafana-data/src/themes/palette.ts new file mode 100644 index 0000000..6b760f0 --- /dev/null +++ b/packages/grafana-data/src/themes/palette.ts @@ -0,0 +1,44 @@ +export const palette = { + white: '#fff', + black: '#000', + + gray25: '#2c3235', + gray15: '#22252b', //'#202226', + gray10: '#181b1f', // old '#141619', + gray05: '#111217', // old '#0b0c0e', + + // new from figma, + darkLayer0: '#18181A', + darkLayer1: '#212124', + darkLayer2: '#2a2a2f', // figma used #34343B but a bit too bright + + darkBorder1: '#34343B', + darkBorder2: '#64646B', + + // Dashboard bg / layer 0 (light theme) + gray90: '#F4F5F5', + // Card bg / layer 1 + gray100: '#F4F5F5', + // divider line + gray80: '#D0D1D3', + // from figma + lightBorder1: '#E4E7E7', + + blueDarkMain: '#3D71D9', // '#4165F5', + blueDarkText: '#6E9FFF', // '#58a6ff', //'#33a2e5', // '#5790FF', + redDarkMain: '#D10E5C', + redDarkText: '#FF5286', + greenDarkMain: '#1A7F4B', + greenDarkText: '#6CCF8E', + orangeDarkMain: '#F5B73D', + orangeDarkText: '#F8D06B', + + blueLightMain: '#3871DC', + blueLightText: '#0465d7', // '#1F62E0', + redLightMain: '#E0226E', + redLightText: '#CF0E5B', + greenLightMain: '#1A7F4B', + greenLightText: '#1A7F4B', + orangeLightMain: '#E56F00', + orangeLightText: '#BD4B00', +}; diff --git a/packages/grafana-data/src/themes/types.ts b/packages/grafana-data/src/themes/types.ts new file mode 100644 index 0000000..d9b4595 --- /dev/null +++ b/packages/grafana-data/src/themes/types.ts @@ -0,0 +1,55 @@ +import { GrafanaTheme } from '../types/theme'; +import { ThemeBreakpoints } from './breakpoints'; +import { ThemeComponents } from './createComponents'; +import { ThemeColors } from './createColors'; +import { ThemeShadows } from './createShadows'; +import { ThemeShape } from './createShape'; +import { ThemeSpacing } from './createSpacing'; +import { ThemeTransitions } from './createTransitions'; +import { ThemeTypography } from './createTypography'; +import { ThemeZIndices } from './zIndex'; +import { ThemeVisualizationColors } from './createVisualizationColors'; + +/** + * @beta + * Next gen theme model introduced in Grafana v8. + */ +export interface GrafanaTheme2 { + name: string; + isDark: boolean; + isLight: boolean; + colors: ThemeColors; + breakpoints: ThemeBreakpoints; + spacing: ThemeSpacing; + shape: ThemeShape; + components: ThemeComponents; + typography: ThemeTypography; + zIndex: ThemeZIndices; + shadows: ThemeShadows; + visualization: ThemeVisualizationColors; + transitions: ThemeTransitions; + v1: GrafanaTheme; +} + +/** @alpha */ +export interface ThemeRichColor { + /** color intent (primary, secondary, info, error, etc) */ + name: string; + /** Main color */ + main: string; + /** Used for hover */ + shade: string; + /** Used for text */ + text: string; + /** Used for borders */ + border: string; + /** Used subtly colored backgrounds */ + transparent: string; + /** Text color for text ontop of main */ + contrastText: string; +} + +/** @internal */ +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; diff --git a/packages/grafana-data/src/themes/zIndex.ts b/packages/grafana-data/src/themes/zIndex.ts new file mode 100644 index 0000000..7f16db4 --- /dev/null +++ b/packages/grafana-data/src/themes/zIndex.ts @@ -0,0 +1,14 @@ +// We need to centralize the zIndex definitions as they work +// like global values in the browser. +export const zIndex = { + navbarFixed: 1000, + sidemenu: 1020, + dropdown: 1030, + typeahead: 1030, + tooltip: 1040, + modalBackdrop: 1050, + modal: 1060, +}; + +/** @beta */ +export type ThemeZIndices = typeof zIndex; diff --git a/packages/grafana-data/src/transformations/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts new file mode 100644 index 0000000..ec22d69 --- /dev/null +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -0,0 +1,165 @@ +import { difference } from 'lodash'; + +import { fieldReducers, ReducerID, reduceField } from './fieldReducer'; + +import { Field, FieldType } from '../types/index'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { ArrayVector } from '../vector/ArrayVector'; + +/** + * Run a reducer and get back the value + */ +function reduce(field: Field, id: string): any { + return reduceField({ field, reducers: [id] })[id]; +} + +function createField(name: string, values?: T[], type?: FieldType): Field { + const arr = new ArrayVector(values); + return { + name, + config: {}, + type: type ? type : guessFieldTypeFromValue(arr.get(0)), + values: arr, + }; +} + +describe('Stats Calculators', () => { + const basicTable = new MutableDataFrame({ + fields: [ + { name: 'a', values: [10, 20] }, + { name: 'b', values: [20, 30] }, + { name: 'c', values: [30, 40] }, + ], + }); + + it('should load all standard stats', () => { + for (const id of Object.keys(ReducerID)) { + const reducer = fieldReducers.getIfExists(id); + const found = reducer ? reducer.id : ''; + expect(found).toEqual(id); + } + }); + + it('should fail to load unknown stats', () => { + const names = ['not a stat', ReducerID.max, ReducerID.min, 'also not a stat']; + const stats = fieldReducers.list(names); + expect(stats.length).toBe(2); + + const found = stats.map((v) => v.id); + const notFound = difference(names, found); + expect(notFound.length).toBe(2); + + expect(notFound[0]).toBe('not a stat'); + }); + + it('should calculate basic stats', () => { + const stats = reduceField({ + field: basicTable.fields[0], + reducers: ['first', 'last', 'mean', 'count'], + }); + + expect(stats.first).toEqual(10); + expect(stats.last).toEqual(20); + expect(stats.mean).toEqual(15); + expect(stats.count).toEqual(2); + }); + + it('should support a single stat also', () => { + basicTable.fields[0].state = undefined; // clear the cache + const stats = reduceField({ + field: basicTable.fields[0], + reducers: ['first'], + }); + + // Should do the simple version that just looks up value + expect(Object.keys(stats).length).toEqual(1); + expect(stats.first).toEqual(10); + }); + + it('should get non standard stats', () => { + const stats = reduceField({ + field: basicTable.fields[0], + reducers: [ReducerID.distinctCount, ReducerID.changeCount], + }); + + expect(stats.distinctCount).toEqual(2); + expect(stats.changeCount).toEqual(1); + }); + + it('should calculate step', () => { + const stats = reduceField({ + field: createField('x', [100, 200, 300, 400]), + reducers: [ReducerID.step, ReducerID.delta], + }); + + expect(stats.step).toEqual(100); + expect(stats.delta).toEqual(300); + }); + + it('consistently check allIsNull/allIsZero', () => { + const empty = createField('x'); + const allNull = createField('x', [null, null, null, null]); + const allUndefined = createField('x', [undefined, undefined, undefined, undefined]); + const allZero = createField('x', [0, 0, 0, 0]); + + expect(reduce(empty, ReducerID.allIsNull)).toEqual(true); + expect(reduce(allNull, ReducerID.allIsNull)).toEqual(true); + expect(reduce(allUndefined, ReducerID.allIsNull)).toEqual(true); + + expect(reduce(empty, ReducerID.allIsZero)).toEqual(false); + expect(reduce(allNull, ReducerID.allIsZero)).toEqual(false); + expect(reduce(allZero, ReducerID.allIsZero)).toEqual(true); + }); + + it('consistent results for first/last value with null', () => { + const info = [ + { + data: [null, 200, null], // first/last value is null + result: 200, + }, + { + data: [null, null, null], // All null + result: null, + }, + { + data: [undefined, undefined, undefined], // Empty row + result: null, + }, + ]; + + const stats = reduceField({ + field: createField('x', info[0].data), + reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull, ReducerID.diffperc], // uses standard path + }); + expect(stats[ReducerID.first]).toEqual(null); + expect(stats[ReducerID.last]).toEqual(null); + expect(stats[ReducerID.firstNotNull]).toEqual(200); + expect(stats[ReducerID.lastNotNull]).toEqual(200); + expect(stats[ReducerID.diffperc]).toEqual(0); + + const reducers = [ReducerID.lastNotNull, ReducerID.firstNotNull]; + for (const input of info) { + for (const reducer of reducers) { + const v1 = reduceField({ + field: createField('x', input.data), + reducers: [reducer, ReducerID.mean], // uses standard path + })[reducer]; + + const v2 = reduceField({ + field: createField('x', input.data), + reducers: [reducer], // uses optimized path + })[reducer]; + + if (v1 !== v2 || v1 !== input.result) { + const msg = + `Invalid ${reducer} result for: ` + + input.data.join(', ') + + ` Expected: ${input.result}` + // configured + ` Received: Multiple: ${v1}, Single: ${v2}`; + expect(msg).toEqual(null); + } + } + } + }); +}); diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts new file mode 100644 index 0000000..7e2d7cc --- /dev/null +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -0,0 +1,448 @@ +// Libraries +import { isNumber } from 'lodash'; + +import { NullValueMode, Field, FieldState, FieldCalcs, FieldType } from '../types/index'; +import { Registry, RegistryItem } from '../utils/Registry'; + +export enum ReducerID { + sum = 'sum', + max = 'max', + min = 'min', + logmin = 'logmin', + mean = 'mean', + last = 'last', + first = 'first', + count = 'count', + range = 'range', + diff = 'diff', + diffperc = 'diffperc', + delta = 'delta', + step = 'step', + + firstNotNull = 'firstNotNull', + lastNotNull = 'lastNotNull', + + changeCount = 'changeCount', + distinctCount = 'distinctCount', + + allIsZero = 'allIsZero', + allIsNull = 'allIsNull', +} + +// Internal function +type FieldReducer = (field: Field, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs; + +export interface FieldReducerInfo extends RegistryItem { + // Internal details + emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero + standard: boolean; // The most common stats can all be calculated in a single pass + reduce?: FieldReducer; +} + +interface ReduceFieldOptions { + field: Field; + reducers: string[]; // The stats to calculate +} + +/** + * @returns an object with a key for each selected stat + * NOTE: This will also modify the 'field.state' object, + * leaving values in a cache until cleared. + */ +export function reduceField(options: ReduceFieldOptions): FieldCalcs { + const { field, reducers } = options; + + if (!field || !reducers || reducers.length < 1) { + return {}; + } + + if (field.state?.calcs) { + // Find the values we need to calculate + const missing: string[] = []; + for (const s of reducers) { + if (!field.state.calcs.hasOwnProperty(s)) { + missing.push(s); + } + } + if (missing.length < 1) { + return { + ...field.state.calcs, + }; + } + } + if (!field.state) { + field.state = {} as FieldState; + } + + const queue = fieldReducers.list(reducers); + + // Return early for empty series + // This lets the concrete implementations assume at least one row + const data = field.values; + if (data.length < 1) { + const calcs = { ...field.state.calcs } as FieldCalcs; + for (const reducer of queue) { + calcs[reducer.id] = reducer.emptyInputResult !== null ? reducer.emptyInputResult : null; + } + return (field.state.calcs = calcs); + } + + const { nullValueMode } = field.config; + const ignoreNulls = nullValueMode === NullValueMode.Ignore; + const nullAsZero = nullValueMode === NullValueMode.AsZero; + + // Avoid calculating all the standard stats if possible + if (queue.length === 1 && queue[0].reduce) { + const values = queue[0].reduce(field, ignoreNulls, nullAsZero); + field.state.calcs = { + ...field.state.calcs, + ...values, + }; + return values; + } + + // For now everything can use the standard stats + let values = doStandardCalcs(field, ignoreNulls, nullAsZero); + + for (const reducer of queue) { + if (!values.hasOwnProperty(reducer.id) && reducer.reduce) { + values = { + ...values, + ...reducer.reduce(field, ignoreNulls, nullAsZero), + }; + } + } + + field.state.calcs = { + ...field.state.calcs, + ...values, + }; + return values; +} + +// ------------------------------------------------------------------------------ +// +// No Exported symbols below here. +// +// ------------------------------------------------------------------------------ + +export const fieldReducers = new Registry(() => [ + { + id: ReducerID.lastNotNull, + name: 'Last (not null)', + description: 'Last non-null value', + standard: true, + aliasIds: ['current'], + reduce: calculateLastNotNull, + }, + { + id: ReducerID.last, + name: 'Last', + description: 'Last Value', + standard: true, + reduce: calculateLast, + }, + { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, + { + id: ReducerID.firstNotNull, + name: 'First (not null)', + description: 'First non-null value', + standard: true, + reduce: calculateFirstNotNull, + }, + { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, + { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, + { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, aliasIds: ['avg'] }, + { + id: ReducerID.sum, + name: 'Total', + description: 'The sum of all values', + emptyInputResult: 0, + standard: true, + aliasIds: ['total'], + }, + { + id: ReducerID.count, + name: 'Count', + description: 'Number of values in response', + emptyInputResult: 0, + standard: true, + }, + { + id: ReducerID.range, + name: 'Range', + description: 'Difference between minimum and maximum values', + standard: true, + }, + { + id: ReducerID.delta, + name: 'Delta', + description: 'Cumulative change in value', + standard: true, + }, + { + id: ReducerID.step, + name: 'Step', + description: 'Minimum interval between values', + standard: true, + }, + { + id: ReducerID.diff, + name: 'Difference', + description: 'Difference between first and last values', + standard: true, + }, + { + id: ReducerID.logmin, + name: 'Min (above zero)', + description: 'Used for log min scale', + standard: true, + }, + { + id: ReducerID.allIsZero, + name: 'All Zeros', + description: 'All values are zero', + emptyInputResult: false, + standard: true, + }, + { + id: ReducerID.allIsNull, + name: 'All Nulls', + description: 'All values are null', + emptyInputResult: true, + standard: true, + }, + { + id: ReducerID.changeCount, + name: 'Change Count', + description: 'Number of times the value changes', + standard: false, + reduce: calculateChangeCount, + }, + { + id: ReducerID.distinctCount, + name: 'Distinct Count', + description: 'Number of distinct values', + standard: false, + reduce: calculateDistinctCount, + }, + { + id: ReducerID.diffperc, + name: 'Difference percent', + description: 'Percentage difference between first and last values', + standard: true, + }, +]); + +export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const calcs = { + sum: 0, + max: -Number.MAX_VALUE, + min: Number.MAX_VALUE, + logmin: Number.MAX_VALUE, + mean: null, + last: null, + first: null, + lastNotNull: null, + firstNotNull: null, + count: 0, + nonNullCount: 0, + allIsNull: true, + allIsZero: true, + range: null, + diff: null, + delta: 0, + step: Number.MAX_VALUE, + diffperc: 0, + + // Just used for calculations -- not exposed as a stat + previousDeltaUp: true, + } as FieldCalcs; + + const data = field.values; + calcs.count = data.length; + + const isNumberField = field.type === FieldType.number || FieldType.time; + + for (let i = 0; i < data.length; i++) { + let currentValue = data.get(i); + + if (i === 0) { + calcs.first = currentValue; + } + + calcs.last = currentValue; + + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + + if (currentValue != null) { + // null || undefined + const isFirst = calcs.firstNotNull === null; + if (isFirst) { + calcs.firstNotNull = currentValue; + } + + if (isNumberField) { + calcs.sum += currentValue; + calcs.allIsNull = false; + calcs.nonNullCount++; + + if (!isFirst) { + const step = currentValue - calcs.lastNotNull!; + if (calcs.step > step) { + calcs.step = step; // the minimum interval + } + + if (calcs.lastNotNull! > currentValue) { + // counter reset + calcs.previousDeltaUp = false; + if (i === data.length - 1) { + // reset on last + calcs.delta += currentValue; + } + } else { + if (calcs.previousDeltaUp) { + calcs.delta += step; // normal increment + } else { + calcs.delta += currentValue; // account for counter reset + } + calcs.previousDeltaUp = true; + } + } + + if (currentValue > calcs.max) { + calcs.max = currentValue; + } + + if (currentValue < calcs.min) { + calcs.min = currentValue; + } + + if (currentValue < calcs.logmin && currentValue > 0) { + calcs.logmin = currentValue; + } + } + + if (currentValue !== 0) { + calcs.allIsZero = false; + } + + calcs.lastNotNull = currentValue; + } + } + + if (calcs.max === -Number.MAX_VALUE) { + calcs.max = null; + } + + if (calcs.min === Number.MAX_VALUE) { + calcs.min = null; + } + + if (calcs.step === Number.MAX_VALUE) { + calcs.step = null; + } + + if (calcs.nonNullCount > 0) { + calcs.mean = calcs.sum! / calcs.nonNullCount; + } + + if (calcs.allIsNull) { + calcs.allIsZero = false; + } + + if (calcs.max !== null && calcs.min !== null) { + calcs.range = calcs.max - calcs.min; + } + + if (isNumber(calcs.firstNotNull) && isNumber(calcs.lastNotNull)) { + calcs.diff = calcs.lastNotNull - calcs.firstNotNull; + } + + if (isNumber(calcs.firstNotNull) && isNumber(calcs.diff)) { + calcs.diffperc = calcs.diff / calcs.firstNotNull; + } + return calcs; +} + +function calculateFirst(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + return { first: field.values.get(0) }; +} + +function calculateFirstNotNull(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + for (let idx = 0; idx < data.length; idx++) { + const v = data.get(idx); + if (v != null && v !== undefined) { + return { firstNotNull: v }; + } + } + return { firstNotNull: null }; +} + +function calculateLast(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + return { last: data.get(data.length - 1) }; +} + +function calculateLastNotNull(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + let idx = data.length - 1; + while (idx >= 0) { + const v = data.get(idx--); + if (v != null && v !== undefined) { + return { lastNotNull: v }; + } + } + return { lastNotNull: null }; +} + +function calculateChangeCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + let count = 0; + let first = true; + let last: any = null; + for (let i = 0; i < data.length; i++) { + let currentValue = data.get(i); + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + if (!first && last !== currentValue) { + count++; + } + first = false; + last = currentValue; + } + + return { changeCount: count }; +} + +function calculateDistinctCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + const distinct = new Set(); + for (let i = 0; i < data.length; i++) { + let currentValue = data.get(i); + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + distinct.add(currentValue); + } + return { distinctCount: distinct.size }; +} diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts new file mode 100644 index 0000000..613f04e --- /dev/null +++ b/packages/grafana-data/src/transformations/index.ts @@ -0,0 +1,15 @@ +export * from './matchers/ids'; +export * from './transformers/ids'; +export * from './matchers'; +export { standardTransformers } from './transformers'; +export * from './fieldReducer'; +export { transformDataFrame } from './transformDataFrame'; +export { + TransformerRegistryItem, + TransformerUIProps, + standardTransformersRegistry, +} from './standardTransformersRegistry'; +export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher'; +export { RenameByRegexTransformerOptions } from './transformers/renameByRegex'; +export { outerJoinDataFrames } from './transformers/joinDataFrames'; +export * from './transformers/histogram'; diff --git a/packages/grafana-data/src/transformations/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts new file mode 100644 index 0000000..aa5e6c7 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -0,0 +1,99 @@ +// Load the Builtin matchers +import { getFieldPredicateMatchers, getFramePredicateMatchers } from './matchers/predicates'; +import { getFieldNameMatchers, getFrameNameMatchers } from './matchers/nameMatcher'; +import { getFieldTypeMatchers } from './matchers/fieldTypeMatcher'; +import { getRefIdMatchers } from './matchers/refIdMatcher'; +import { + FieldMatcherInfo, + MatcherConfig, + FrameMatcherInfo, + FieldMatcher, + FrameMatcher, + ValueMatcherInfo, + ValueMatcher, +} from '../types/transformations'; +import { Registry } from '../utils/Registry'; +import { getNullValueMatchers } from './matchers/valueMatchers/nullMatchers'; +import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatchers'; +import { getEqualValueMatchers } from './matchers/valueMatchers/equalMatchers'; +import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers'; +import { getSimpleFieldMatchers } from './matchers/simpleFieldMatcher'; +import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers'; + +/** + * Registry that contains all of the built in field matchers. + * @public + */ +export const fieldMatchers = new Registry(() => { + return [ + ...getFieldPredicateMatchers(), // Predicates + ...getFieldTypeMatchers(), // by type + ...getFieldNameMatchers(), // by name + ...getSimpleFieldMatchers(), // first + ]; +}); + +/** + * Registry that contains all of the built in frame matchers. + * @public + */ +export const frameMatchers = new Registry(() => { + return [ + ...getFramePredicateMatchers(), // Predicates + ...getFrameNameMatchers(), // by name + ...getRefIdMatchers(), // by query refId + ]; +}); + +/** + * Registry that contains all of the built in value matchers. + * @public + */ +export const valueMatchers = new Registry(() => { + return [ + ...getNullValueMatchers(), + ...getNumericValueMatchers(), + ...getEqualValueMatchers(), + ...getRangeValueMatchers(), + ...getRegexValueMatcher(), + ]; +}); + +/** + * Resolves a field matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getFieldMatcher(config: MatcherConfig): FieldMatcher { + const info = fieldMatchers.get(config.id); + if (!info) { + throw new Error('Unknown field matcher: ' + config.id); + } + return info.get(config.options); +} + +/** + * Resolves a frame matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getFrameMatchers(config: MatcherConfig): FrameMatcher { + const info = frameMatchers.get(config.id); + if (!info) { + throw new Error('Unknown frame matcher: ' + config.id); + } + return info.get(config.options); +} + +/** + * Resolves a value matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getValueMatcher(config: MatcherConfig): ValueMatcher { + const info = valueMatchers.get(config.id); + if (!info) { + throw new Error('Unknown value matcher: ' + config.id); + } + return info.get(config.options); +} diff --git a/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts new file mode 100644 index 0000000..c6370b0 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts @@ -0,0 +1,23 @@ +import { FieldType } from '../../types/dataFrame'; +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; + +export const simpleSeriesWithTypes = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.time }, + { name: 'B', type: FieldType.boolean }, + { name: 'C', type: FieldType.string }, + ], +}); + +describe('Field Type Matcher', () => { + const matcher = fieldMatchers.get(FieldMatcherID.byType); + it('finds numbers', () => { + for (const field of simpleSeriesWithTypes.fields) { + const matches = matcher.get(FieldType.number); + const didMatch = matches(field, simpleSeriesWithTypes, [simpleSeriesWithTypes]); + expect(didMatch).toBe(field.type === FieldType.number); + } + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts new file mode 100644 index 0000000..4cb8201 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts @@ -0,0 +1,59 @@ +import { Field, FieldType, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherID } from './ids'; +import { FieldMatcherInfo } from '../../types/transformations'; + +// General Field matcher +const fieldTypeMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byType, + name: 'Field Type', + description: 'match based on the field type', + defaultOptions: FieldType.number, + + get: (type: FieldType) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return type === field.type; + }; + }, + + getOptionsDisplayText: (type: FieldType) => { + return `Field type: ${type}`; + }, +}; + +// Numeric Field matcher +// This gets its own entry so it shows up in the dropdown +const numericMatcher: FieldMatcherInfo = { + id: FieldMatcherID.numeric, + name: 'Numeric Fields', + description: 'Fields with type number', + + get: () => { + return fieldTypeMatcher.get(FieldType.number); + }, + + getOptionsDisplayText: () => { + return 'Numeric Fields'; + }, +}; + +// Time Field matcher +const timeMatcher: FieldMatcherInfo = { + id: FieldMatcherID.time, + name: 'Time Fields', + description: 'Fields with type time', + + get: () => { + return fieldTypeMatcher.get(FieldType.time); + }, + + getOptionsDisplayText: () => { + return 'Time Fields'; + }, +}; + +/** + * Registry Initialization + */ +export function getFieldTypeMatchers(): FieldMatcherInfo[] { + return [fieldTypeMatcher, numericMatcher, timeMatcher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts new file mode 100644 index 0000000..cd4c788 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -0,0 +1,55 @@ +// This needs to be in its own file to avoid circular references + +// Builtin Predicates +// not using 'any' and 'never' since they are reserved keywords +export enum MatcherID { + anyMatch = 'anyMatch', // checks children + allMatch = 'allMatch', // checks children + invertMatch = 'invertMatch', // checks child + alwaysMatch = 'alwaysMatch', + neverMatch = 'neverMatch', +} + +export enum FieldMatcherID { + // Specific Types + numeric = 'numeric', + time = 'time', // Can be multiple times + first = 'first', + firstTimeField = 'firstTimeField', // Only the first fime field + + // With arguments + byType = 'byType', + byName = 'byName', + byNames = 'byNames', + byRegexp = 'byRegexp', + byRegexpOrNames = 'byRegexpOrNames', + byFrameRefID = 'byFrameRefID', + // byIndex = 'byIndex', + // byLabel = 'byLabel', +} + +/** + * Field name matchers + */ +export enum FrameMatcherID { + byName = 'byName', + byRefId = 'byRefId', + byIndex = 'byIndex', + byLabel = 'byLabel', +} + +/** + * @public + */ +export enum ValueMatcherID { + regex = 'regex', + isNull = 'isNull', + isNotNull = 'isNotNull', + greater = 'greater', + greaterOrEqual = 'greaterOrEqual', + lower = 'lower', + lowerOrEqual = 'lowerOrEqual', + equal = 'equal', + notEqual = 'notEqual', + between = 'between', +} diff --git a/packages/grafana-data/src/transformations/matchers/matchers.test.ts b/packages/grafana-data/src/transformations/matchers/matchers.test.ts new file mode 100644 index 0000000..81ea76b --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/matchers.test.ts @@ -0,0 +1,11 @@ +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from './ids'; + +describe('Matchers', () => { + it('should load all matchers', () => { + for (const name of Object.keys(FieldMatcherID)) { + const matcher = fieldMatchers.get(name); + expect(matcher.id).toBe(name); + } + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts new file mode 100644 index 0000000..42a653a --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -0,0 +1,388 @@ +import { getFieldMatcher } from '../matchers'; +import { FieldMatcherID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { ByNamesMatcherMode } from './nameMatcher'; + +describe('Field Name by Regexp Matcher', () => { + it('Match all with wildcard regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexp, + options: '/.*/', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match all with decimals regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexp, + options: '/^\\d+$/', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match complex regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexp, + options: '/\\b(?:\\S+?\\.)+\\S+\\b$/', + }; + + const matcher = getFieldMatcher(config); + let resultCount = 0; + for (const field of seriesWithNames.fields) { + if (matcher(field, seriesWithNames, [seriesWithNames])) { + resultCount++; + } + expect(resultCount).toBe(1); + } + }); +}); + +describe('Field Name Matcher', () => { + it('Match only exact name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byName, + options: 'C', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match should respect letter case', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byName, + options: 'c', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match none of the field names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byName, + options: '', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); +}); + +describe('Field Multiple Names Matcher', () => { + it('Match only exact name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match should default to include mode', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match should respect letter case', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: ['c'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match none of the field names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: [], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match all of the field names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: ['some.instance.path', '112', '13'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match all but supplied names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.exclude, + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name !== 'C'); + } + }); +}); + +describe('Field Regexp or Names Matcher', () => { + it('Match only exact name by name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match all starting with AA', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + pattern: '/^AA/', + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'AAA'); + } + }); + + it('Match all starting with AA and C', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + pattern: '/^AA/', + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'AAA' || field.name === 'C'); + } + }); + + it('Match should respect letter case by name if not igored in pattern', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: ['c'], + pattern: '/c/i', + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match none of the field names by name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: [], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match all of the field names by name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: ['some.instance.path', '112', '13'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match all of the field names by regexp', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + pattern: '/.*/', + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); +}); + +describe('Fields returned by query with refId', () => { + it('Match all fields in frame with refId: A', () => { + const data = [ + toDataFrame({ + refId: 'A', + fields: [{ name: 'field_1' }, { name: 'field_2' }], + }), + toDataFrame({ + refId: 'B', + fields: [{ name: 'field_1' }, { name: 'field_2' }], + }), + ]; + + const matcher = getFieldMatcher({ + id: FieldMatcherID.byFrameRefID, + options: 'A', + }); + + const frameA = data[0]; + expect(matcher(frameA.fields[0], frameA, data)).toBe(true); + expect(matcher(frameA.fields[1], frameA, data)).toBe(true); + + const frameB = data[1]; + expect(matcher(frameB.fields[0], frameB, data)).toBe(false); + expect(matcher(frameB.fields[1], frameB, data)).toBe(false); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts new file mode 100644 index 0000000..24609ad --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -0,0 +1,200 @@ +import { Field, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherID, FrameMatcherID } from './ids'; +import { FieldMatcherInfo, FrameMatcherInfo, FieldMatcher } from '../../types/transformations'; +import { stringToJsRegex } from '../../text/string'; +import { getFieldDisplayName } from '../../field/fieldState'; + +export interface RegexpOrNamesMatcherOptions { + pattern?: string; + names?: string[]; +} + +/** + * Mode to be able to toggle if the names matcher should match fields in provided + * list or all except provided names. + * @public + */ +export enum ByNamesMatcherMode { + exclude = 'exclude', + include = 'include', +} + +/** + * Options to instruct the by names matcher to either match all fields in given list + * or all except the fields in the list. + * @public + */ +export interface ByNamesMatcherOptions { + mode?: ByNamesMatcherMode; + names?: string[]; + readOnly?: boolean; + prefix?: string; +} + +// General Field matcher +const fieldNameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byName, + name: 'Field Name', + description: 'match the field name', + defaultOptions: '', + + get: (name: string): FieldMatcher => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return getFieldDisplayName(field, frame, allFrames) === name; + }; + }, + + getOptionsDisplayText: (name: string) => { + return `Field name: ${name}`; + }, +}; + +const multipleFieldNamesMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byNames, + name: 'Field Names', + description: 'match any of the given the field names', + defaultOptions: { + mode: ByNamesMatcherMode.include, + names: [], + }, + + get: (options: ByNamesMatcherOptions): FieldMatcher => { + const { names, mode = ByNamesMatcherMode.include } = options; + const uniqueNames = new Set(names ?? []); + + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + if (mode === ByNamesMatcherMode.exclude) { + return !uniqueNames.has(getFieldDisplayName(field, frame, allFrames)); + } + return uniqueNames.has(getFieldDisplayName(field, frame, allFrames)); + }; + }, + + getOptionsDisplayText: (options: ByNamesMatcherOptions): string => { + const { names, mode } = options; + const displayText = (names ?? []).join(', '); + if (mode === ByNamesMatcherMode.exclude) { + return `All except: ${displayText}`; + } + return `All of: ${displayText}`; + }, +}; + +const regexpFieldNameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byRegexp, + name: 'Field Name by Regexp', + description: 'match the field name by a given regexp pattern', + defaultOptions: '/.*/', + + get: (pattern: string): FieldMatcher => { + const regexp = patternToRegex(pattern); + + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + const displayName = getFieldDisplayName(field, frame, allFrames); + return !!regexp && regexp.test(displayName); + }; + }, + + getOptionsDisplayText: (pattern: string): string => { + return `Field name by pattern: ${pattern}`; + }, +}; + +/** + * Field matcher that will match all fields that exists in a + * data frame with configured refId. + * @public + */ +const fieldsInFrameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byFrameRefID, + name: 'Fields by frame refId', + description: 'match all fields returned in data frame with refId.', + defaultOptions: '', + + get: (refId: string): FieldMatcher => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return frame.refId === refId; + }; + }, + + getOptionsDisplayText: (refId: string): string => { + return `Math all fields returned by query with reference ID: ${refId}`; + }, +}; + +const regexpOrMultipleNamesMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byRegexpOrNames, + name: 'Field Name by Regexp or Names', + description: 'match the field name by a given regexp pattern or given names', + defaultOptions: { + pattern: '/.*/', + names: [], + }, + + get: (options: RegexpOrNamesMatcherOptions): FieldMatcher => { + const regexpMatcher = regexpFieldNameMatcher.get(options?.pattern || ''); + const namesMatcher = multipleFieldNamesMatcher.get({ + mode: ByNamesMatcherMode.include, + names: options?.names ?? [], + }); + + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return namesMatcher(field, frame, allFrames) || regexpMatcher(field, frame, allFrames); + }; + }, + + getOptionsDisplayText: (options: RegexpOrNamesMatcherOptions): string => { + const pattern = options?.pattern ?? ''; + const names = options?.names?.join(',') ?? ''; + return `Field name by pattern: ${pattern} or names: ${names}`; + }, +}; + +const patternToRegex = (pattern?: string): RegExp | undefined => { + if (!pattern) { + return undefined; + } + + try { + return stringToJsRegex(pattern); + } catch (error) { + console.error(error); + return undefined; + } +}; + +// General Frame matcher +const frameNameMatcher: FrameMatcherInfo = { + id: FrameMatcherID.byName, + name: 'Frame Name', + description: 'match the frame name', + defaultOptions: '/.*/', + + get: (pattern: string) => { + const regex = stringToJsRegex(pattern); + return (frame: DataFrame) => { + return regex.test(frame.name || ''); + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `Frame name: ${pattern}`; + }, +}; + +/** + * Registry Initialization + */ +export function getFieldNameMatchers(): FieldMatcherInfo[] { + return [ + fieldNameMatcher, + regexpFieldNameMatcher, + multipleFieldNamesMatcher, + regexpOrMultipleNamesMatcher, + fieldsInFrameMatcher, + ]; +} + +export function getFrameNameMatchers(): FrameMatcherInfo[] { + return [frameNameMatcher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/predicates.test.ts b/packages/grafana-data/src/transformations/matchers/predicates.test.ts new file mode 100644 index 0000000..ef007b5 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/predicates.test.ts @@ -0,0 +1,41 @@ +import { FieldType } from '../../types/dataFrame'; +import { fieldMatchers } from '../matchers'; +import { simpleSeriesWithTypes } from './fieldTypeMatcher.test'; +import { FieldMatcherID, MatcherID } from './ids'; +import { MatcherConfig } from '../../types/transformations'; + +const matchesNumberConfig: MatcherConfig = { + id: FieldMatcherID.byType, + options: FieldType.number, +}; +const matchesTimeConfig: MatcherConfig = { + id: FieldMatcherID.byType, + options: FieldType.time, +}; +const both = [matchesNumberConfig, matchesTimeConfig]; +const allFrames = [simpleSeriesWithTypes]; + +describe('Check Predicates', () => { + it('can not match both', () => { + const matches = fieldMatchers.get(MatcherID.allMatch).get(both); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(false); + } + }); + + it('match either time or number', () => { + const matches = fieldMatchers.get(MatcherID.anyMatch).get(both); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe( + field.type === FieldType.number || field.type === FieldType.time + ); + } + }); + + it('match not time', () => { + const matches = fieldMatchers.get(MatcherID.invertMatch).get(matchesTimeConfig); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(field.type !== FieldType.time); + } + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/predicates.ts b/packages/grafana-data/src/transformations/matchers/predicates.ts new file mode 100644 index 0000000..cf0b2a9 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/predicates.ts @@ -0,0 +1,265 @@ +import { Field, DataFrame, FieldType } from '../../types/dataFrame'; +import { MatcherID } from './ids'; +import { getFieldMatcher, fieldMatchers, getFrameMatchers, frameMatchers } from '../matchers'; +import { FieldMatcherInfo, MatcherConfig, FrameMatcherInfo } from '../../types/transformations'; + +const anyFieldMatcher: FieldMatcherInfo = { + id: MatcherID.anyMatch, + name: 'Any', + description: 'Any child matches (OR)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFieldMatcher(option); + }); + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + for (const child of children) { + if (child(field, frame, allFrames)) { + return true; + } + } + return false; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' OR '; + } + const matcher = fieldMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const anyFrameMatcher: FrameMatcherInfo = { + id: MatcherID.anyMatch, + name: 'Any', + description: 'Any child matches (OR)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFrameMatchers(option); + }); + return (frame: DataFrame) => { + for (const child of children) { + if (child(frame)) { + return true; + } + } + return false; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' OR '; + } + const matcher = frameMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const allFieldsMatcher: FieldMatcherInfo = { + id: MatcherID.allMatch, + name: 'All', + description: 'Everything matches (AND)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFieldMatcher(option); + }); + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + for (const child of children) { + if (!child(field, frame, allFrames)) { + return false; + } + } + return true; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' AND '; + } + const matcher = fieldMatchers.get(sub.id); // Ugho what about frame + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const allFramesMatcher: FrameMatcherInfo = { + id: MatcherID.allMatch, + name: 'All', + description: 'Everything matches (AND)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFrameMatchers(option); + }); + return (frame: DataFrame) => { + for (const child of children) { + if (!child(frame)) { + return false; + } + } + return true; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' AND '; + } + const matcher = frameMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const notFieldMatcher: FieldMatcherInfo = { + id: MatcherID.invertMatch, + name: 'NOT', + description: 'Inverts other matchers', + excludeFromPicker: true, + + get: (option: MatcherConfig) => { + const check = getFieldMatcher(option); + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return !check(field, frame, allFrames); + }; + }, + + getOptionsDisplayText: (options: MatcherConfig) => { + const matcher = fieldMatchers.get(options.id); + const text = matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(options.options) : matcher.name; + return 'NOT ' + text; + }, +}; + +const notFrameMatcher: FrameMatcherInfo = { + id: MatcherID.invertMatch, + name: 'NOT', + description: 'Inverts other matchers', + excludeFromPicker: true, + + get: (option: MatcherConfig) => { + const check = getFrameMatchers(option); + return (frame: DataFrame) => { + return !check(frame); + }; + }, + + getOptionsDisplayText: (options: MatcherConfig) => { + const matcher = frameMatchers.get(options.id); + const text = matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(options.options) : matcher.name; + return 'NOT ' + text; + }, +}; + +export const alwaysFieldMatcher = (field: Field) => { + return true; +}; + +export const alwaysFrameMatcher = (frame: DataFrame) => { + return true; +}; + +export const neverFieldMatcher = (field: Field) => { + return false; +}; + +export const notTimeFieldMatcher = (field: Field) => { + return field.type !== FieldType.time; +}; + +export const neverFrameMatcher = (frame: DataFrame) => { + return false; +}; + +const alwaysFieldMatcherInfo: FieldMatcherInfo = { + id: MatcherID.alwaysMatch, + name: 'All Fields', + description: 'Always Match', + + get: (option: any) => { + return alwaysFieldMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Always'; + }, +}; + +const alwaysFrameMatcherInfo: FrameMatcherInfo = { + id: MatcherID.alwaysMatch, + name: 'All Frames', + description: 'Always Match', + + get: (option: any) => { + return alwaysFrameMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Always'; + }, +}; + +const neverFieldMatcherInfo: FieldMatcherInfo = { + id: MatcherID.neverMatch, + name: 'No Fields', + description: 'Never Match', + excludeFromPicker: true, + + get: (option: any) => { + return neverFieldMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Never'; + }, +}; + +const neverFrameMatcherInfo: FrameMatcherInfo = { + id: MatcherID.neverMatch, + name: 'No Frames', + description: 'Never Match', + + get: (option: any) => { + return neverFrameMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Never'; + }, +}; + +export function getFieldPredicateMatchers(): FieldMatcherInfo[] { + return [anyFieldMatcher, allFieldsMatcher, notFieldMatcher, alwaysFieldMatcherInfo, neverFieldMatcherInfo]; +} + +export function getFramePredicateMatchers(): FrameMatcherInfo[] { + return [anyFrameMatcher, allFramesMatcher, notFrameMatcher, alwaysFrameMatcherInfo, neverFrameMatcherInfo]; +} diff --git a/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts new file mode 100644 index 0000000..a8e0aa9 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts @@ -0,0 +1,27 @@ +import { DataFrame } from '../../types/dataFrame'; +import { FrameMatcherID } from './ids'; +import { FrameMatcherInfo } from '../../types/transformations'; +import { stringToJsRegex } from '../../text'; + +// General Field matcher +const refIdMacher: FrameMatcherInfo = { + id: FrameMatcherID.byRefId, + name: 'Query refId', + description: 'match the refId', + defaultOptions: 'A', + + get: (pattern: string) => { + const regex = stringToJsRegex(pattern); + return (frame: DataFrame) => { + return regex.test(frame.refId || ''); + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `RefID: ${pattern}`; + }, +}; + +export function getRefIdMatchers(): FrameMatcherInfo[] { + return [refIdMacher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts b/packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts new file mode 100644 index 0000000..b32dfaf --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts @@ -0,0 +1,42 @@ +import { Field, FieldType, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherID } from './ids'; +import { FieldMatcherInfo } from '../../types/transformations'; + +const firstFieldMatcher: FieldMatcherInfo = { + id: FieldMatcherID.first, + name: 'First Field', + description: 'The first field in the frame', + + get: (type: FieldType) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return field === frame.fields[0]; + }; + }, + + getOptionsDisplayText: () => { + return `First field`; + }, +}; + +const firstTimeFieldMatcher: FieldMatcherInfo = { + id: FieldMatcherID.firstTimeField, + name: 'First time field', + description: 'The first field of type time in a frame', + + get: (type: FieldType) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return field.type === FieldType.time && field === frame.fields.find((f) => f.type === FieldType.time); + }; + }, + + getOptionsDisplayText: () => { + return `First time field`; + }, +}; + +/** + * Registry Initialization + */ +export function getSimpleFieldMatchers(): FieldMatcherInfo[] { + return [firstFieldMatcher, firstTimeFieldMatcher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts new file mode 100644 index 0000000..2aba608 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts @@ -0,0 +1,108 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value equals to matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.equal, + options: { + value: 23, + }, + }); + + it('should match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); +}); + +describe('value not equals matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.notEqual, + options: { + value: 23, + }, + }); + + it('should not match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts new file mode 100644 index 0000000..c6b3dea --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts @@ -0,0 +1,42 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.equal, + name: 'Is equal', + description: 'Match where value for given field is equal to options value.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value == options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +const isNotEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.notEqual, + name: 'Is not equal', + description: 'Match where value for given field is not equal to options value.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value != options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +export const getEqualValueMatchers = (): ValueMatcherInfo[] => [isEqualValueMatcher, isNotEqualValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts new file mode 100644 index 0000000..91b1f06 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts @@ -0,0 +1,72 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNull, + options: {}, + }); + + it('should match null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value not null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNotNull, + options: {}, + }); + + it('should match not null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts new file mode 100644 index 0000000..42ce8b1 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts @@ -0,0 +1,40 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { ValueMatcherOptions } from './types'; + +const isNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNull, + name: 'Is null', + description: 'Match where value for given field is null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value === null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +const isNotNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNotNull, + name: 'Is not null', + description: 'Match where value for given field is not null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value !== null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +export const getNullValueMatchers = (): ValueMatcherInfo[] => [isNullValueMatcher, isNotNullValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts new file mode 100644 index 0000000..7c20d91 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts @@ -0,0 +1,180 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value greater than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greater, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value greater than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greaterOrEqual, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lower, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lowerOrEqual, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts new file mode 100644 index 0000000..129b659 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts @@ -0,0 +1,91 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isGreaterValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greater, + name: 'Is greater', + description: 'Match when field value is greater than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is greater than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isGreaterOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greaterOrEqual, + name: 'Is greater or equal', + description: 'Match when field value is lower or greater than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value >= options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is lower or greater than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lower, + name: 'Is lower', + description: 'Match when field value is lower than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value < options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is lower than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lowerOrEqual, + name: 'Is lower or equal', + description: 'Match when field value is lower or equal than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value <= options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is lower or equal than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +export const getNumericValueMatchers = (): ValueMatcherInfo[] => [ + isGreaterValueMatcher, + isGreaterOrEqualValueMatcher, + isLowerValueMatcher, + isLowerOrEqualValueMatcher, +]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts new file mode 100644 index 0000000..710ed50 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts @@ -0,0 +1,49 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value between matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10, 25], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.between, + options: { + from: 10, + to: 25, + }, + }); + + it('should match values greater than 10 but lower than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts new file mode 100644 index 0000000..17edb0d --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts @@ -0,0 +1,26 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { RangeValueMatcherOptions } from './types'; + +const isBetweenValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.between, + name: 'Is between', + description: 'Match when field value is between given option values.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.from && value < options.to; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is between ${options.from} and ${options.to}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ from: 0, to: 100 }), +}; + +export const getRangeValueMatchers = (): ValueMatcherInfo[] => [isBetweenValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts new file mode 100644 index 0000000..379a6cf --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts @@ -0,0 +1,85 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('regex value matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: ['.', 'asdf', 100, '25.5'], + }, + ], + }), + ]; + + describe('option with value .*', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '.*', + }, + }); + + it('should match all values', () => { + const frame = data[0]; + const field = frame.fields[0]; + + for (let i = 0; i < field.values.length; i++) { + expect(matcher(i, field, frame, data)).toBeTruthy(); + } + }); + }); + + describe('option with value \\w+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\w+', + }, + }); + + it('should match wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); + + describe('option with value \\d+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\d+', + }, + }); + + it('should match numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts new file mode 100644 index 0000000..b4b1590 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts @@ -0,0 +1,25 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const regexValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.regex, + name: 'Regex', + description: 'Match when field value is matching regex.', + get: (options) => { + const regex = new RegExp(options.value); + + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return regex.test(value); + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is matching regex: ${options.value}`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '.*' }), +}; + +export const getRegexValueMatcher = (): ValueMatcherInfo[] => [regexValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts new file mode 100644 index 0000000..f292129 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts @@ -0,0 +1,23 @@ +/** + * Describes a empty value matcher option. + * @public + */ +export interface ValueMatcherOptions {} + +/** + * Describes a basic value matcher option that has a single value. + * @public + */ +export interface BasicValueMatcherOptions extends ValueMatcherOptions { + value: T; +} + +/** + * Describes a range value matcher option that has a to and a from value to + * be able to match a range. + * @public + */ +export interface RangeValueMatcherOptions extends ValueMatcherOptions { + from: T; + to: T; +} diff --git a/packages/grafana-data/src/transformations/standardTransformersRegistry.ts b/packages/grafana-data/src/transformations/standardTransformersRegistry.ts new file mode 100644 index 0000000..62a804f --- /dev/null +++ b/packages/grafana-data/src/transformations/standardTransformersRegistry.ts @@ -0,0 +1,32 @@ +import React from 'react'; +import { DataFrame, DataTransformerInfo } from '../types'; +import { Registry, RegistryItem } from '../utils/Registry'; + +export interface TransformerUIProps { + /** + * Transformer configuration, persisted on panel's model + */ + options: T; + /** + * Pre-transform data frames + */ + input: DataFrame[]; + onChange: (options: T) => void; +} + +export interface TransformerRegistryItem extends RegistryItem { + /** + * Object describing transformer configuration + */ + transformation: DataTransformerInfo; + /** + * React component used as UI for the transformer + */ + editor: React.ComponentType>; +} + +/** + * Registry of transformation options that can be driven by + * stored configuration files. + */ +export const standardTransformersRegistry = new Registry>(); diff --git a/packages/grafana-data/src/transformations/transformDataFrame.ts b/packages/grafana-data/src/transformations/transformDataFrame.ts new file mode 100644 index 0000000..dbcb019 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformDataFrame.ts @@ -0,0 +1,68 @@ +import { MonoTypeOperatorFunction, Observable, of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; + +import { DataFrame, DataTransformerConfig } from '../types'; +import { standardTransformersRegistry, TransformerRegistryItem } from './standardTransformersRegistry'; + +const getOperator = (config: DataTransformerConfig): MonoTypeOperatorFunction => (source) => { + const info = standardTransformersRegistry.get(config.id); + + if (!info) { + return source; + } + + const defaultOptions = info.transformation.defaultOptions ?? {}; + const options = { ...defaultOptions, ...config.options }; + + return source.pipe( + mergeMap((before) => of(before).pipe(info.transformation.operator(options), postProcessTransform(before, info))) + ); +}; + +const postProcessTransform = ( + before: DataFrame[], + info: TransformerRegistryItem +): MonoTypeOperatorFunction => (source) => + source.pipe( + map((after) => { + if (after === before) { + return after; + } + + // Add a key to the metadata if the data changed + for (const series of after) { + if (!series.meta) { + series.meta = {}; + } + + if (!series.meta.transformations) { + series.meta.transformations = [info.id]; + } else { + series.meta.transformations = [...series.meta.transformations, info.id]; + } + } + + return after; + }) + ); + +/** + * Apply configured transformations to the input data + */ +export function transformDataFrame(options: DataTransformerConfig[], data: DataFrame[]): Observable { + const stream = of(data); + + if (!options.length) { + return stream; + } + + const operators: Array> = []; + + for (let index = 0; index < options.length; index++) { + const config = options[index]; + operators.push(getOperator(config)); + } + + // @ts-ignore TypeScript has a hard time understanding this construct + return stream.pipe.apply(stream, operators); +} diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts new file mode 100644 index 0000000..deaed1a --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -0,0 +1,44 @@ +import { reduceTransformer } from './transformers/reduce'; +import { concatenateTransformer } from './transformers/concat'; +import { calculateFieldTransformer } from './transformers/calculateField'; +import { filterFieldsTransformer, filterFramesTransformer } from './transformers/filter'; +import { filterFieldsByNameTransformer } from './transformers/filterByName'; +import { noopTransformer } from './transformers/noop'; +import { filterFramesByRefIdTransformer } from './transformers/filterByRefId'; +import { orderFieldsTransformer } from './transformers/order'; +import { organizeFieldsTransformer } from './transformers/organize'; +import { seriesToColumnsTransformer } from './transformers/seriesToColumns'; +import { seriesToRowsTransformer } from './transformers/seriesToRows'; +import { renameFieldsTransformer } from './transformers/rename'; +import { labelsToFieldsTransformer } from './transformers/labelsToFields'; +import { ensureColumnsTransformer } from './transformers/ensureColumns'; +import { groupByTransformer } from './transformers/groupBy'; +import { sortByTransformer } from './transformers/sortBy'; +import { mergeTransformer } from './transformers/merge'; +import { renameByRegexTransformer } from './transformers/renameByRegex'; +import { filterByValueTransformer } from './transformers/filterByValue'; +import { histogramTransformer } from './transformers/histogram'; + +export const standardTransformers = { + noopTransformer, + filterFieldsTransformer, + filterFieldsByNameTransformer, + filterFramesTransformer, + filterFramesByRefIdTransformer, + filterByValueTransformer, + orderFieldsTransformer, + organizeFieldsTransformer, + reduceTransformer, + concatenateTransformer, + calculateFieldTransformer, + seriesToColumnsTransformer, + seriesToRowsTransformer, + renameFieldsTransformer, + labelsToFieldsTransformer, + ensureColumnsTransformer, + groupByTransformer, + sortByTransformer, + mergeTransformer, + renameByRegexTransformer, + histogramTransformer, +}; diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.test.ts b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts new file mode 100644 index 0000000..d119af9 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts @@ -0,0 +1,222 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { ReducerID } from '../fieldReducer'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { CalculateFieldMode, calculateFieldTransformer, ReduceOptions } from './calculateField'; +import { DataFrameView } from '../../dataframe'; +import { BinaryOperationID } from '../../utils'; + +const seriesA = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], +}); + +const seriesBC = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.number, values: [2, 200] }, + { name: 'C', type: FieldType.number, values: [3, 300] }, + { name: 'D', type: FieldType.string, values: ['first', 'second'] }, + { name: 'E', type: FieldType.boolean, values: [true, false] }, + ], +}); + +describe('calculateField transformer w/ timeseries', () => { + beforeAll(() => { + mockTransformationsRegistry([calculateFieldTransformer]); + }); + + it('will filter and alias', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + // defaults to `sum` ReduceRow + alias: 'The Total', + }, + }; + + await expect(transformDataFrame([cfg], [seriesA, seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + A: 1, + B: 2, + C: 3, + D: 'first', + E: true, + 'The Total': 6, + TheTime: 1000, + }, + { + A: 100, + B: 200, + C: 300, + D: 'second', + E: false, + 'The Total': 600, + TheTime: 2000, + }, + ]); + }); + }); + + it('will replace other fields', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.ReduceRow, + reduce: { + reducer: ReducerID.mean, + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesA, seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + Mean: 2, + TheTime: 1000, + }, + { + Mean: 200, + TheTime: 2000, + }, + ]); + }); + }); + + it('will filter by name', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.ReduceRow, + reduce: { + include: ['B'], + reducer: ReducerID.mean, + } as ReduceOptions, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + Mean: 2, + TheTime: 1000, + }, + { + Mean: 200, + TheTime: 2000, + }, + ]); + }); + }); + + it('binary math', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.BinaryOperation, + binary: { + left: 'B', + operator: BinaryOperationID.Add, + right: 'C', + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + 'B + C': 5, + TheTime: 1000, + }, + { + 'B + C': 500, + TheTime: 2000, + }, + ]); + }); + }); + + it('field + static number', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.BinaryOperation, + binary: { + left: 'B', + operator: BinaryOperationID.Add, + right: '2', + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + 'B + 2': 4, + TheTime: 1000, + }, + { + 'B + 2': 202, + TheTime: 2000, + }, + ]); + }); + }); + + it('boolean field', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.BinaryOperation, + binary: { + left: 'E', + operator: BinaryOperationID.Multiply, + right: '1', + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toMatchInlineSnapshot(` + Array [ + Object { + "E * 1": 1, + "TheTime": 1000, + }, + Object { + "E * 1": 0, + "TheTime": 2000, + }, + ] + `); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.ts b/packages/grafana-data/src/transformations/transformers/calculateField.ts new file mode 100644 index 0000000..2ee6ca9 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/calculateField.ts @@ -0,0 +1,238 @@ +import { map } from 'rxjs/operators'; + +import { DataFrame, DataTransformerInfo, Field, FieldType, NullValueMode, Vector } from '../../types'; +import { DataTransformerID } from './ids'; +import { doStandardCalcs, fieldReducers, ReducerID } from '../fieldReducer'; +import { getFieldMatcher } from '../matchers'; +import { FieldMatcherID } from '../matchers/ids'; +import { RowVector } from '../../vector/RowVector'; +import { ArrayVector, BinaryOperationVector, ConstantVector } from '../../vector'; +import { AsNumberVector } from '../../vector/AsNumberVector'; +import { getTimeField } from '../../dataframe/processDataFrame'; +import { defaults } from 'lodash'; +import { BinaryOperationID, binaryOperators } from '../../utils/binaryOperators'; +import { ensureColumnsTransformer } from './ensureColumns'; +import { getFieldDisplayName } from '../../field'; +import { noopTransformer } from './noop'; + +export enum CalculateFieldMode { + ReduceRow = 'reduceRow', + BinaryOperation = 'binary', +} + +export interface ReduceOptions { + include?: string[]; // Assume all fields + reducer: ReducerID; + nullValueMode?: NullValueMode; +} + +export interface BinaryOptions { + left: string; + operator: BinaryOperationID; + right: string; +} + +const defaultReduceOptions: ReduceOptions = { + reducer: ReducerID.sum, +}; + +const defaultBinaryOptions: BinaryOptions = { + left: '', + operator: BinaryOperationID.Add, + right: '', +}; + +export interface CalculateFieldTransformerOptions { + // True/False or auto + timeSeries?: boolean; + mode: CalculateFieldMode; // defaults to 'reduce' + + // Only one should be filled + reduce?: ReduceOptions; + binary?: BinaryOptions; + + // Remove other fields + replaceFields?: boolean; + + // Output field properties + alias?: string; // The output field name + // TODO: config?: FieldConfig; or maybe field overrides? since the UI exists +} + +type ValuesCreator = (data: DataFrame) => Vector; + +export const calculateFieldTransformer: DataTransformerInfo = { + id: DataTransformerID.calculateField, + name: 'Add field from calculation', + description: 'Use the row values to calculate a new field', + defaultOptions: { + mode: CalculateFieldMode.ReduceRow, + reduce: { + reducer: ReducerID.sum, + }, + }, + operator: (options) => (outerSource) => { + const operator = + options && options.timeSeries !== false ? ensureColumnsTransformer.operator(null) : noopTransformer.operator({}); + + return outerSource.pipe( + operator, + map((data) => { + const mode = options.mode ?? CalculateFieldMode.ReduceRow; + let creator: ValuesCreator | undefined = undefined; + + if (mode === CalculateFieldMode.ReduceRow) { + creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions), data); + } else if (mode === CalculateFieldMode.BinaryOperation) { + creator = getBinaryCreator(defaults(options.binary, defaultBinaryOptions), data); + } + + // Nothing configured + if (!creator) { + return data; + } + + return data.map((frame) => { + // delegate field creation to the specific function + const values = creator!(frame); + if (!values) { + return frame; + } + + const field = { + name: getNameFromOptions(options), + type: FieldType.number, + config: {}, + values, + }; + let fields: Field[] = []; + + // Replace all fields with the single field + if (options.replaceFields) { + const { timeField } = getTimeField(frame); + if (timeField && options.timeSeries !== false) { + fields = [timeField, field]; + } else { + fields = [field]; + } + } else { + fields = [...frame.fields, field]; + } + return { + ...frame, + fields, + }; + }); + }) + ); + }, +}; + +function getReduceRowCreator(options: ReduceOptions, allFrames: DataFrame[]): ValuesCreator { + let matcher = getFieldMatcher({ + id: FieldMatcherID.numeric, + }); + + if (options.include && options.include.length) { + matcher = getFieldMatcher({ + id: FieldMatcherID.byNames, + options: { + names: options.include, + }, + }); + } + + const info = fieldReducers.get(options.reducer); + + if (!info) { + throw new Error(`Unknown reducer: ${options.reducer}`); + } + + const reducer = info.reduce ?? doStandardCalcs; + const ignoreNulls = options.nullValueMode === NullValueMode.Ignore; + const nullAsZero = options.nullValueMode === NullValueMode.AsZero; + + return (frame: DataFrame) => { + // Find the columns that should be examined + const columns: Vector[] = []; + for (const field of frame.fields) { + if (matcher(field, frame, allFrames)) { + columns.push(field.values); + } + } + + // Prepare a "fake" field for the row + const iter = new RowVector(columns); + const row: Field = { + name: 'temp', + values: iter, + type: FieldType.number, + config: {}, + }; + const vals: number[] = []; + + for (let i = 0; i < frame.length; i++) { + iter.rowIndex = i; + const val = reducer(row, ignoreNulls, nullAsZero)[options.reducer]; + vals.push(val); + } + + return new ArrayVector(vals); + }; +} + +function findFieldValuesWithNameOrConstant(frame: DataFrame, name: string, allFrames: DataFrame[]): Vector | undefined { + if (!name) { + return undefined; + } + + for (const f of frame.fields) { + if (name === getFieldDisplayName(f, frame, allFrames)) { + if (f.type === FieldType.boolean) { + return new AsNumberVector(f.values); + } + return f.values; + } + } + + const v = parseFloat(name); + if (!isNaN(v)) { + return new ConstantVector(v, frame.length); + } + + return undefined; +} + +function getBinaryCreator(options: BinaryOptions, allFrames: DataFrame[]): ValuesCreator { + const operator = binaryOperators.getIfExists(options.operator); + + return (frame: DataFrame) => { + const left = findFieldValuesWithNameOrConstant(frame, options.left, allFrames); + const right = findFieldValuesWithNameOrConstant(frame, options.right, allFrames); + if (!left || !right || !operator) { + return (undefined as unknown) as Vector; + } + + return new BinaryOperationVector(left, right, operator.operation); + }; +} + +export function getNameFromOptions(options: CalculateFieldTransformerOptions) { + if (options.alias?.length) { + return options.alias; + } + + if (options.mode === CalculateFieldMode.BinaryOperation) { + const { binary } = options; + return `${binary?.left ?? ''} ${binary?.operator ?? ''} ${binary?.right ?? ''}`; + } + + if (options.mode === CalculateFieldMode.ReduceRow) { + const r = fieldReducers.getIfExists(options.reduce?.reducer); + if (r) { + return r.name; + } + } + + return 'math'; +} diff --git a/packages/grafana-data/src/transformations/transformers/concat.test.ts b/packages/grafana-data/src/transformations/transformers/concat.test.ts new file mode 100644 index 0000000..f7d0932 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/concat.test.ts @@ -0,0 +1,136 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { concatenateFields, ConcatenateFrameNameMode } from './concat'; + +export const simpleABC = toDataFrame({ + name: 'ABC', + fields: [ + { name: 'A', values: [1, 2] }, + { name: 'B', values: [1, 2] }, + { name: 'C', values: [1, 2] }, + ], +}); + +export const simpleXYZ = toDataFrame({ + name: 'XYZ', + fields: [ + { name: 'X', values: [1, 2, 3] }, + { name: 'Y', values: [1, 2, 3] }, + { name: 'Z', values: [1, 2, 3] }, + ], +}); + +describe('Concat Transformer', () => { + it('dropping frame name', () => { + const frame = concatenateFields([simpleABC, simpleXYZ], { frameNameMode: ConcatenateFrameNameMode.Drop }); + expect(frame.length).toBe(3); + expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` + Array [ + Object { + "labels": undefined, + "name": "A", + }, + Object { + "labels": undefined, + "name": "B", + }, + Object { + "labels": undefined, + "name": "C", + }, + Object { + "labels": undefined, + "name": "X", + }, + Object { + "labels": undefined, + "name": "Y", + }, + Object { + "labels": undefined, + "name": "Z", + }, + ] + `); + }); + + it('using field name', () => { + const frame = concatenateFields([simpleABC, simpleXYZ], { frameNameMode: ConcatenateFrameNameMode.FieldName }); + expect(frame.length).toBe(3); + expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` + Array [ + Object { + "labels": undefined, + "name": "ABC · A", + }, + Object { + "labels": undefined, + "name": "ABC · B", + }, + Object { + "labels": undefined, + "name": "ABC · C", + }, + Object { + "labels": undefined, + "name": "XYZ · X", + }, + Object { + "labels": undefined, + "name": "XYZ · Y", + }, + Object { + "labels": undefined, + "name": "XYZ · Z", + }, + ] + `); + }); + + it('using field label', () => { + const frame = concatenateFields([simpleABC, simpleXYZ], { + frameNameMode: ConcatenateFrameNameMode.Label, + frameNameLabel: 'sensor', + }); + expect(frame.length).toBe(3); + expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` + Array [ + Object { + "labels": Object { + "sensor": "ABC", + }, + "name": "A", + }, + Object { + "labels": Object { + "sensor": "ABC", + }, + "name": "B", + }, + Object { + "labels": Object { + "sensor": "ABC", + }, + "name": "C", + }, + Object { + "labels": Object { + "sensor": "XYZ", + }, + "name": "X", + }, + Object { + "labels": Object { + "sensor": "XYZ", + }, + "name": "Y", + }, + Object { + "labels": Object { + "sensor": "XYZ", + }, + "name": "Z", + }, + ] + `); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/concat.ts b/packages/grafana-data/src/transformations/transformers/concat.ts new file mode 100644 index 0000000..e7489d3 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/concat.ts @@ -0,0 +1,105 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field, TIME_SERIES_VALUE_FIELD_NAME } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector'; + +export enum ConcatenateFrameNameMode { + /** + * Ignore the source frame name when moving to the destination + */ + Drop = 'drop', + + /** + * Copy the source frame name to the destination field. The final field will contain + * both the frame and field name + */ + FieldName = 'field', + + /** + * Copy the source frame name to a label on the field. The label key is controlled + * by frameNameLabel + */ + Label = 'label', +} + +export interface ConcatenateTransformerOptions { + frameNameMode?: ConcatenateFrameNameMode; + frameNameLabel?: string; +} + +export const concatenateTransformer: DataTransformerInfo = { + id: DataTransformerID.concatenate, + name: 'Concatenate fields', + description: + 'Combine all fields into a single frame. Values will be appended with undefined values if not the same length.', + defaultOptions: { + frameNameMode: ConcatenateFrameNameMode.FieldName, + frameNameLabel: 'frame', + }, + operator: (options) => (source) => + source.pipe( + map((dataFrames) => { + if (!Array.isArray(dataFrames) || dataFrames.length < 2) { + return dataFrames; // noop with single frame + } + return [concatenateFields(dataFrames, options)]; + }) + ), +}; + +/** + * @internal only exported for tests + */ +export function concatenateFields(data: DataFrame[], opts: ConcatenateTransformerOptions): DataFrame { + let sameLength = true; + let maxLength = data[0].length; + const frameNameLabel = opts.frameNameLabel ?? 'frame'; + let fields: Field[] = []; + + for (const frame of data) { + if (maxLength !== frame.length) { + sameLength = false; + maxLength = Math.max(maxLength, frame.length); + } + + for (const f of frame.fields) { + const copy = { ...f }; + copy.state = undefined; + if (frame.name) { + if (opts.frameNameMode === ConcatenateFrameNameMode.Drop) { + // nothing -- skip the name + } else if (opts.frameNameMode === ConcatenateFrameNameMode.Label) { + copy.labels = { ...f.labels }; + copy.labels[frameNameLabel] = frame.name; + } else if (!copy.name || copy.name === TIME_SERIES_VALUE_FIELD_NAME) { + copy.name = frame.name; + } else { + copy.name = `${frame.name} · ${f.name}`; + } + } + fields.push(copy); + } + } + + // Make sure all fields have the same length + if (!sameLength) { + fields = fields.map((f) => { + if (f.values.length === maxLength) { + return f; + } + const values = f.values.toArray(); + values.length = maxLength; + return { + ...f, + values: new ArrayVector(values), + }; + }); + } + + return { + fields, + length: maxLength, + }; +} diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts new file mode 100644 index 0000000..7b7d349 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts @@ -0,0 +1,144 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { ensureColumnsTransformer } from './ensureColumns'; +import { seriesToColumnsTransformer } from './seriesToColumns'; + +const seriesA = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], +}); + +const seriesBC = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.number, values: [2, 200] }, + { name: 'C', type: FieldType.number, values: [3, 300] }, + { name: 'D', type: FieldType.string, values: ['first', 'second'] }, + ], +}); + +const seriesNoTime = toDataFrame({ + fields: [ + { name: 'B', type: FieldType.number, values: [2, 200] }, + { name: 'C', type: FieldType.number, values: [3, 300] }, + { name: 'D', type: FieldType.string, values: ['first', 'second'] }, + ], +}); + +describe('ensureColumns transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([ensureColumnsTransformer, seriesToColumnsTransformer]); + }); + + it('will transform to columns if time field exists and multiple frames', async () => { + const cfg = { + id: DataTransformerID.ensureColumns, + options: {}, + }; + + const data = [seriesA, seriesBC]; + + await expect(transformDataFrame([cfg], data)).toEmitValuesWith((received) => { + const filtered = received[0]; + expect(filtered.length).toEqual(1); + + const frame = filtered[0]; + expect(frame.fields.length).toEqual(5); + expect(filtered[0]).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "name": "TheTime", + "state": Object { + "displayName": "TheTime", + }, + "type": "time", + "values": Array [ + 1000, + 2000, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "A", + "state": Object {}, + "type": "number", + "values": Array [ + 1, + 100, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "B", + "state": Object {}, + "type": "number", + "values": Array [ + 2, + 200, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "C", + "state": Object {}, + "type": "number", + "values": Array [ + 3, + 300, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "D", + "state": Object {}, + "type": "string", + "values": Array [ + "first", + "second", + ], + }, + ], + "length": 2, + "meta": Object { + "transformations": Array [ + "ensureColumns", + ], + }, + } + `); + }); + }); + + it('will not transform to columns if time field is missing for any of the series', async () => { + const cfg = { + id: DataTransformerID.ensureColumns, + options: {}, + }; + + const data = [seriesBC, seriesNoTime]; + + await expect(transformDataFrame([cfg], data)).toEmitValues([data]); + }); + + it('will not transform to columns if only one series', async () => { + const cfg = { + id: DataTransformerID.ensureColumns, + options: {}, + }; + + const data = [seriesBC]; + + await expect(transformDataFrame([cfg], data)).toEmitValues([data]); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.ts new file mode 100644 index 0000000..b8068ce --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.ts @@ -0,0 +1,51 @@ +import { of } from 'rxjs'; + +import { seriesToColumnsTransformer } from './seriesToColumns'; +import { DataFrame } from '../../types/dataFrame'; +import { getTimeField } from '../../dataframe/processDataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataTransformerID } from './ids'; +import { mergeMap } from 'rxjs/operators'; + +export const ensureColumnsTransformer: DataTransformerInfo = { + id: DataTransformerID.ensureColumns, + name: 'Ensure Columns Transformer', + description: 'Will check if current data frames is series or columns. If in series it will convert to columns.', + operator: (options = {}) => (source) => + source.pipe( + mergeMap((data) => { + // Assume timeseries should first be joined by time + const timeFieldName = findConsistentTimeFieldName(data); + + if (data.length > 1 && timeFieldName) { + return of(data).pipe( + seriesToColumnsTransformer.operator({ + byField: timeFieldName, + }) + ); + } + + return of(data); + }) + ), +}; + +/** + * Find the name for the time field used in all frames (if one exists) + */ +function findConsistentTimeFieldName(data: DataFrame[]): string | undefined { + let name: string | undefined = undefined; + for (const frame of data) { + const { timeField } = getTimeField(frame); + if (!timeField) { + return undefined; // Not timeseries + } + if (!name) { + name = timeField.name; + } else if (name !== timeField.name) { + // Second frame has a different time column?! + return undefined; + } + } + return name; +} diff --git a/packages/grafana-data/src/transformations/transformers/filter.test.ts b/packages/grafana-data/src/transformations/transformers/filter.test.ts new file mode 100644 index 0000000..525acc9 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filter.test.ts @@ -0,0 +1,38 @@ +import { FieldType } from '../../types/dataFrame'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldMatcherID } from '../matchers/ids'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { filterFieldsTransformer } from './filter'; +import { transformDataFrame } from '../transformDataFrame'; + +export const simpleSeriesWithTypes = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.boolean, values: [true, false] }, + { name: 'C', type: FieldType.string, values: ['a', 'b'] }, + { name: 'D', type: FieldType.number, values: [1, 2] }, + ], +}); + +describe('Filter Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterFieldsTransformer]); + }); + + it('filters by include', async () => { + const cfg = { + id: DataTransformerID.filterFields, + options: { + include: { id: FieldMatcherID.numeric }, + }, + }; + + await expect(transformDataFrame([cfg], [simpleSeriesWithTypes])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('D'); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filter.ts b/packages/grafana-data/src/transformations/transformers/filter.ts new file mode 100644 index 0000000..4ab327f --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filter.ts @@ -0,0 +1,107 @@ +import { map } from 'rxjs/operators'; + +import { noopTransformer } from './noop'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { getFieldMatcher, getFrameMatchers } from '../matchers'; + +export interface FilterOptions { + include?: MatcherConfig; + exclude?: MatcherConfig; +} + +export const filterFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFields, + name: 'Filter Fields', + description: 'select a subset of fields', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options: FilterOptions) => (source) => { + if (!options.include && !options.exclude) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map((data) => { + const include = options.include ? getFieldMatcher(options.include) : null; + const exclude = options.exclude ? getFieldMatcher(options.exclude) : null; + + const processed: DataFrame[] = []; + for (const series of data) { + // Find the matching field indexes + const fields: Field[] = []; + for (let i = 0; i < series.fields.length; i++) { + const field = series.fields[i]; + + if (exclude) { + if (exclude(field, series, data)) { + continue; + } + if (!include) { + fields.push(field); + } + } + if (include && include(field, series, data)) { + fields.push(field); + } + } + + if (!fields.length) { + continue; + } + const copy = { + ...series, // all the other properties + fields, // but a different set of fields + }; + processed.push(copy); + } + return processed; + }) + ); + }, +}; + +export const filterFramesTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFrames, + name: 'Filter Frames', + description: 'select a subset of frames', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => { + if (!options.include && !options.exclude) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map((data) => { + const include = options.include ? getFrameMatchers(options.include) : null; + const exclude = options.exclude ? getFrameMatchers(options.exclude) : null; + + const processed: DataFrame[] = []; + for (const series of data) { + if (exclude) { + if (exclude(series)) { + continue; + } + if (!include) { + processed.push(series); + } + } + if (include && include(series)) { + processed.push(series); + } + } + return processed; + }) + ); + }, +}; diff --git a/packages/grafana-data/src/transformations/transformers/filterByName.test.ts b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts new file mode 100644 index 0000000..9d4f06c --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts @@ -0,0 +1,198 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { filterFieldsByNameTransformer } from './filterByName'; +import { filterFieldsTransformer } from './filter'; +import { transformDataFrame } from '../transformDataFrame'; + +export const seriesWithNamesToMatch = toDataFrame({ + fields: [ + { name: 'startsWithA', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.boolean, values: [true, false] }, + { name: 'startsWithC', type: FieldType.string, values: ['a', 'b'] }, + { name: 'D', type: FieldType.number, values: [1, 2] }, + ], +}); + +describe('filterByName transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterFieldsByNameTransformer, filterFieldsTransformer]); + }); + + it('returns original series if no options provided', async () => { + const cfg = { + id: DataTransformerID.filterFields, + options: {}, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(4); + }); + }); + + describe('respects', () => { + it('inclusion by pattern', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: { + pattern: '/^(startsWith)/', + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + }); + + it('exclusion by pattern', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { + pattern: '/^(startsWith)/', + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion and exclusion by pattern', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { pattern: '/^(startsWith)/' }, + include: { pattern: '/^(B)$/' }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion by names', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: { + names: ['startsWithA', 'startsWithC'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + }); + + it('exclusion by names', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { + names: ['startsWithA', 'startsWithC'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion and exclusion by names', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { names: ['startsWithA', 'startsWithC'] }, + include: { names: ['B'] }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion by both', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: { + pattern: '/^(startsWith)/', + names: ['startsWithA'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + }); + + it('exclusion by both', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { + pattern: '/^(startsWith)/', + names: ['startsWithA'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion and exclusion by both', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { names: ['startsWithA', 'startsWithC'] }, + include: { pattern: '/^(B)$/' }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByName.ts b/packages/grafana-data/src/transformations/transformers/filterByName.ts new file mode 100644 index 0000000..f6f58cb --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByName.ts @@ -0,0 +1,51 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { FieldMatcherID } from '../matchers/ids'; +import { filterFieldsTransformer } from './filter'; +import { RegexpOrNamesMatcherOptions } from '../matchers/nameMatcher'; + +export interface FilterFieldsByNameTransformerOptions { + include?: RegexpOrNamesMatcherOptions; + exclude?: RegexpOrNamesMatcherOptions; +} + +export const filterFieldsByNameTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFieldsByName, + name: 'Filter fields by name', + description: 'select a subset of fields', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + filterFieldsTransformer.operator({ + include: getMatcherConfig(options.include), + exclude: getMatcherConfig(options.exclude), + }) + ), +}; + +const getMatcherConfig = (options?: RegexpOrNamesMatcherOptions): MatcherConfig | undefined => { + if (!options) { + return undefined; + } + + const { names, pattern } = options; + + if ((!Array.isArray(names) || names.length === 0) && !pattern) { + return undefined; + } + + if (!pattern) { + return { id: FieldMatcherID.byNames, options: { names } }; + } + + if (!Array.isArray(names) || names.length === 0) { + return { id: FieldMatcherID.byRegexp, options: pattern }; + } + + return { id: FieldMatcherID.byRegexpOrNames, options }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts b/packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts new file mode 100644 index 0000000..a989e28 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts @@ -0,0 +1,54 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { filterFramesByRefIdTransformer } from './filterByRefId'; +import { transformDataFrame } from '../transformDataFrame'; + +export const allSeries = [ + toDataFrame({ + refId: 'A', + fields: [], + }), + toDataFrame({ + refId: 'B', + fields: [], + }), + toDataFrame({ + refId: 'C', + fields: [], + }), +]; + +describe('filterByRefId transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterFramesByRefIdTransformer]); + }); + + it('returns all series if no options provided', async () => { + const cfg = { + id: DataTransformerID.filterByRefId, + options: {}, + }; + + await expect(transformDataFrame([cfg], allSeries)).toEmitValuesWith((received) => { + const filtered = received[0]; + expect(filtered.length).toBe(3); + }); + }); + + describe('respects', () => { + it('inclusion', async () => { + const cfg = { + id: DataTransformerID.filterByRefId, + options: { + include: 'A|B', + }, + }; + + await expect(transformDataFrame([cfg], allSeries)).toEmitValuesWith((received) => { + const filtered = received[0]; + expect(filtered.map((f) => f.refId)).toEqual(['A', 'B']); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByRefId.ts b/packages/grafana-data/src/transformations/transformers/filterByRefId.ts new file mode 100644 index 0000000..25b7b09 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByRefId.ts @@ -0,0 +1,38 @@ +import { DataTransformerID } from './ids'; +import { filterFramesTransformer, FilterOptions } from './filter'; +import { DataTransformerInfo } from '../../types/transformations'; +import { FrameMatcherID } from '../matchers/ids'; + +export interface FilterFramesByRefIdTransformerOptions { + include?: string; + exclude?: string; +} + +export const filterFramesByRefIdTransformer: DataTransformerInfo = { + id: DataTransformerID.filterByRefId, + name: 'Filter data by query refId', + description: 'select a subset of results', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => { + const filterOptions: FilterOptions = {}; + if (options.include) { + filterOptions.include = { + id: FrameMatcherID.byRefId, + options: options.include, + }; + } + if (options.exclude) { + filterOptions.exclude = { + id: FrameMatcherID.byRefId, + options: options.exclude, + }; + } + + return source.pipe(filterFramesTransformer.operator(filterOptions)); + }, +}; diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts new file mode 100644 index 0000000..062bb89 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts @@ -0,0 +1,211 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, FieldType, MatcherConfig } from '../../types'; +import { ArrayVector } from '../../vector'; +import { transformDataFrame } from '../transformDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { + FilterByValueMatch, + filterByValueTransformer, + FilterByValueTransformerOptions, + FilterByValueType, +} from './filterByValue'; +import { DataTransformerID } from './ids'; +import { ValueMatcherID } from '../matchers/ids'; +import { BasicValueMatcherOptions } from '../matchers/valueMatchers/types'; + +const seriesAWithSingleField = toDataFrame({ + name: 'A', + length: 7, + fields: [ + { name: 'time', type: FieldType.time, values: new ArrayVector([1000, 2000, 3000, 4000, 5000, 6000, 7000]) }, + { name: 'numbers', type: FieldType.number, values: new ArrayVector([1, 2, 3, 4, 5, 6, 7]) }, + ], +}); + +describe('FilterByValue transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterByValueTransformer]); + }); + + it('should exclude values', async () => { + const lower: MatcherConfig> = { + id: ValueMatcherID.lower, + options: { value: 6 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.exclude, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lower, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([6000, 7000]), + state: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([6, 7]), + state: {}, + }, + ]); + }); + }); + + it('should include values', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 5000]), + state: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 5]), + state: {}, + }, + ]); + }); + }); + + it('should match any condition', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 4 }, + }; + + const equal: MatcherConfig> = { + id: ValueMatcherID.equal, + options: { value: 7 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.any, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: equal, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 7000]), + state: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 7]), + state: {}, + }, + ]); + }); + }); + + it('should match all condition', async () => { + const greaterOrEqual: MatcherConfig> = { + id: ValueMatcherID.greaterOrEqual, + options: { value: 4 }, + }; + + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: greaterOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([4000, 5000]), + state: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([4, 5]), + state: {}, + }, + ]); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.ts new file mode 100644 index 0000000..9994821 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.ts @@ -0,0 +1,159 @@ +import { map } from 'rxjs/operators'; + +import { noopTransformer } from './noop'; +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { getValueMatcher } from '../matchers'; +import { ArrayVector } from '../../vector/ArrayVector'; + +export enum FilterByValueType { + exclude = 'exclude', + include = 'include', +} + +export enum FilterByValueMatch { + all = 'all', + any = 'any', +} + +export interface FilterByValueFilter { + fieldName: string; + config: MatcherConfig; +} + +export interface FilterByValueTransformerOptions { + filters: FilterByValueFilter[]; + type: FilterByValueType; + match: FilterByValueMatch; +} + +export const filterByValueTransformer: DataTransformerInfo = { + id: DataTransformerID.filterByValue, + name: 'Filter data by values', + description: 'select a subset of results based on values', + defaultOptions: { + filters: [], + type: FilterByValueType.include, + match: FilterByValueMatch.any, + }, + + operator: (options) => (source) => { + const filters = options.filters; + const matchAll = options.match === FilterByValueMatch.all; + const include = options.type === FilterByValueType.include; + + if (!Array.isArray(filters) || filters.length === 0) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + const rows = new Set(); + + for (const frame of data) { + const fieldIndexByName = groupFieldIndexByName(frame, data); + const matchers = createFilterValueMatchers(filters, fieldIndexByName); + + for (let index = 0; index < frame.length; index++) { + if (rows.has(index)) { + continue; + } + + let matching = true; + + for (const matcher of matchers) { + const match = matcher(index, frame, data); + + if (!matchAll && match) { + matching = true; + break; + } + + if (matchAll && !match) { + matching = false; + break; + } + + matching = match; + } + + if (matching) { + rows.add(index); + } + } + } + + const processed: DataFrame[] = []; + const frameLength = include ? rows.size : data[0].length - rows.size; + + for (const frame of data) { + const fields: Field[] = []; + + for (const field of frame.fields) { + const buffer = []; + + for (let index = 0; index < frame.length; index++) { + if (include && rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + + if (!include && !rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + } + + // We keep field config, but clean the state as it's being recalculated when the field overrides are applied + fields.push({ + ...field, + values: new ArrayVector(buffer), + state: {}, + }); + } + + processed.push({ + ...frame, + fields: fields, + length: frameLength, + }); + } + + return processed; + }) + ); + }, +}; + +const createFilterValueMatchers = ( + filters: FilterByValueFilter[], + fieldIndexByName: Record +): Array<(index: number, frame: DataFrame, data: DataFrame[]) => boolean> => { + const noop = () => false; + + return filters.map((filter) => { + const fieldIndex = fieldIndexByName[filter.fieldName] ?? -1; + + if (fieldIndex < 0) { + console.warn(`[FilterByValue] Could not find index for field name: ${filter.fieldName}`); + return noop; + } + + const matcher = getValueMatcher(filter.config); + return (index, frame, data) => matcher(index, frame.fields[fieldIndex], frame, data); + }); +}; + +const groupFieldIndexByName = (frame: DataFrame, data: DataFrame[]): Record => { + return frame.fields.reduce((all: Record, field, fieldIndex) => { + const fieldName = getFieldDisplayName(field, frame, data); + all[fieldName] = fieldIndex; + return all; + }, {}); +}; diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.test.ts b/packages/grafana-data/src/transformations/transformers/groupBy.test.ts new file mode 100644 index 0000000..337de2f --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupBy.test.ts @@ -0,0 +1,263 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { GroupByOperationID, groupByTransformer, GroupByTransformerOptions } from './groupBy'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { ArrayVector } from '../../vector'; +import { ReducerID } from '../fieldReducer'; +import { DataTransformerConfig } from '@grafana/data'; + +describe('GroupBy transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([groupByTransformer]); + }); + + it('should not apply transformation if config is missing group by fields', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.count], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0]).toBe(testSeries); + }); + }); + + it('should group values by message', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group values by message and summarize values', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([1, 4, 9]), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group by and compute a few calculations for each group of values', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + time: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.count, ReducerID.last], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'time (count)', + type: FieldType.number, + values: new ArrayVector([1, 2, 3]), + config: {}, + }, + { + name: 'time (last)', + type: FieldType.time, + values: new ArrayVector([3000, 5000, 8000]), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([1, 4, 9]), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group values in data frames individually', async () => { + const testSeries = [ + toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }), + toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [0, 2, 5, 3, 3, 2] }, + ], + }), + ]; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], testSeries)).toEmitValuesWith((received) => { + const result = received[0]; + const expectedA: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([1, 4, 9]), + config: {}, + }, + ]; + + const expectedB: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([0, 7, 8]), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expectedA); + expect(result[1].fields).toEqual(expectedB); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts new file mode 100644 index 0000000..ae70dba --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -0,0 +1,188 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { reduceField, ReducerID } from '../fieldReducer'; +import { MutableField } from '../../dataframe/MutableDataFrame'; + +export enum GroupByOperationID { + aggregate = 'aggregate', + groupBy = 'groupby', +} + +export interface GroupByFieldOptions { + aggregations: ReducerID[]; + operation: GroupByOperationID | null; +} + +export interface GroupByTransformerOptions { + fields: Record; +} + +export const groupByTransformer: DataTransformerInfo = { + id: DataTransformerID.groupBy, + name: 'Group by', + description: 'Group the data by a field values then process calculations for each group', + defaultOptions: { + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const hasValidConfig = Object.keys(options.fields).find( + (name) => options.fields[name].operation === GroupByOperationID.groupBy + ); + + if (!hasValidConfig) { + return data; + } + + const processed: DataFrame[] = []; + + for (const frame of data) { + const groupByFields: Field[] = []; + + for (const field of frame.fields) { + if (shouldGroupOnField(field, options)) { + groupByFields.push(field); + } + } + + if (groupByFields.length === 0) { + continue; // No group by field in this frame, ignore the frame + } + + // Group the values by fields and groups so we can get all values for a + // group for a given field. + const valuesByGroupKey: Record> = {}; + for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { + const groupKey = String(groupByFields.map((field) => field.values.get(rowIndex))); + const valuesByField = valuesByGroupKey[groupKey] ?? {}; + + if (!valuesByGroupKey[groupKey]) { + valuesByGroupKey[groupKey] = valuesByField; + } + + for (let field of frame.fields) { + const fieldName = getFieldDisplayName(field); + + if (!valuesByField[fieldName]) { + valuesByField[fieldName] = { + name: fieldName, + type: field.type, + config: { ...field.config }, + values: new ArrayVector(), + }; + } + + valuesByField[fieldName].values.add(field.values.get(rowIndex)); + } + } + + const fields: Field[] = []; + const groupKeys = Object.keys(valuesByGroupKey); + + for (const field of groupByFields) { + const values = new ArrayVector(); + const fieldName = getFieldDisplayName(field); + + for (let key of groupKeys) { + const valuesByField = valuesByGroupKey[key]; + values.add(valuesByField[fieldName].values.get(0)); + } + + fields.push({ + name: field.name, + type: field.type, + config: { + ...field.config, + }, + values: values, + }); + } + + // Then for each calculations configured, compute and add a new field (column) + for (const field of frame.fields) { + if (!shouldCalculateField(field, options)) { + continue; + } + + const fieldName = getFieldDisplayName(field); + const aggregations = options.fields[fieldName].aggregations; + const valuesByAggregation: Record = {}; + + for (const groupKey of groupKeys) { + const fieldWithValuesForGroup = valuesByGroupKey[groupKey][fieldName]; + const results = reduceField({ + field: fieldWithValuesForGroup, + reducers: aggregations, + }); + + for (const aggregation of aggregations) { + if (!Array.isArray(valuesByAggregation[aggregation])) { + valuesByAggregation[aggregation] = []; + } + valuesByAggregation[aggregation].push(results[aggregation]); + } + } + + for (const aggregation of aggregations) { + const aggregationField: Field = { + name: `${fieldName} (${aggregation})`, + values: new ArrayVector(valuesByAggregation[aggregation]), + type: FieldType.other, + config: {}, + }; + + aggregationField.type = detectFieldType(aggregation, field, aggregationField); + fields.push(aggregationField); + } + } + + processed.push({ + fields, + length: groupKeys.length, + }); + } + + return processed; + }) + ), +}; + +const shouldGroupOnField = (field: Field, options: GroupByTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return options?.fields[fieldName]?.operation === GroupByOperationID.groupBy; +}; + +const shouldCalculateField = (field: Field, options: GroupByTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return ( + options?.fields[fieldName]?.operation === GroupByOperationID.aggregate && + Array.isArray(options?.fields[fieldName].aggregations) && + options?.fields[fieldName].aggregations.length > 0 + ); +}; + +const detectFieldType = (aggregation: string, sourceField: Field, targetField: Field): FieldType => { + switch (aggregation) { + case ReducerID.allIsNull: + return FieldType.boolean; + case ReducerID.last: + case ReducerID.lastNotNull: + case ReducerID.first: + case ReducerID.firstNotNull: + return sourceField.type; + default: + return guessFieldTypeForField(targetField) ?? FieldType.string; + } +}; diff --git a/packages/grafana-data/src/transformations/transformers/histogram.test.ts b/packages/grafana-data/src/transformations/transformers/histogram.test.ts new file mode 100644 index 0000000..883e625 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/histogram.test.ts @@ -0,0 +1,172 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { histogramTransformer, buildHistogram, histogramFieldsToFrame } from './histogram'; + +describe('histogram frames frames', () => { + beforeAll(() => { + mockTransformationsRegistry([histogramTransformer]); + }); + + it('by first time field', () => { + const series1 = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.number, values: [1, 2, 3, 4, 5] }, + { name: 'B', type: FieldType.number, values: [3, 4, 5, 6, 7] }, + { name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }, + ], + }); + + const series2 = toDataFrame({ + fields: [{ name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }], + }); + + const out = histogramFieldsToFrame(buildHistogram([series1, series2])!); + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "BucketMin", + "values": Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + }, + Object { + "name": "BucketMax", + "values": Array [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + ], + }, + Object { + "name": "A", + "values": Array [ + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + ], + }, + Object { + "name": "B", + "values": Array [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + ], + }, + Object { + "name": "C", + "values": Array [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + ], + }, + Object { + "name": "C", + "values": Array [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + ], + }, + ] + `); + + const out2 = histogramFieldsToFrame(buildHistogram([series1, series2], { combine: true })!); + expect( + out2.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "BucketMin", + "values": Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + }, + Object { + "name": "BucketMax", + "values": Array [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + ], + }, + Object { + "name": "Count", + "values": Array [ + 1, + 1, + 2, + 2, + 4, + 3, + 3, + 2, + 2, + ], + }, + ] + `); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts new file mode 100644 index 0000000..3c0e470 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -0,0 +1,306 @@ +import { DataTransformerInfo } from '../../types'; +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { AlignedData, join } from './joinDataFrames'; + +/* eslint-disable */ +// prettier-ignore +/** + * @internal + */ +export const histogramBucketSizes = [ + .001, .002, .0025, .005, + .01, .02, .025, .05, + .1, .2, .25, .5, + 1, 2, 4, 5, + 10, 20, 25, 50, + 100, 200, 250, 500, + 1000, 2000, 2500, 5000, +]; +/* eslint-enable */ + +const histFilter = [null]; +const histSort = (a: number, b: number) => a - b; + +/** + * @alpha + */ +export interface HistogramTransformerOptions { + bucketSize?: number; // 0 is auto + bucketOffset?: number; + // xMin?: number; + // xMax?: number; + combine?: boolean; // if multiple series are input, join them into one +} + +/** + * This is a helper class to use the same text in both a panel and transformer UI + * + * @internal + */ +export const histogramFieldInfo = { + bucketSize: { + name: 'Bucket size', + description: undefined, + }, + bucketOffset: { + name: 'Bucket offset', + description: 'for non-zero-based buckets', + }, + combine: { + name: 'Combine series', + description: 'combine all series into a single histogram', + }, +}; + +/** + * @alpha + */ +export const histogramTransformer: DataTransformerInfo = { + id: DataTransformerID.histogram, + name: 'Histogram', + description: 'Calculate a histogram from input data', + defaultOptions: { + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + const hist = buildHistogram(data, options); + if (hist == null) { + return []; + } + return [histogramFieldsToFrame(hist)]; + }) + ), +}; + +/** + * @internal + */ +export const histogramFrameBucketMinFieldName = 'BucketMin'; + +/** + * @internal + */ +export const histogramFrameBucketMaxFieldName = 'BucketMax'; + +/** + * @alpha + */ +export interface HistogramFields { + bucketMin: Field; + bucketMax: Field; + counts: Field[]; // frequency +} + +/** + * Given a frame, find the explicit histogram fields + * + * @alpha + */ +export function getHistogramFields(frame: DataFrame): HistogramFields | undefined { + let bucketMin: Field | undefined = undefined; + let bucketMax: Field | undefined = undefined; + const counts: Field[] = []; + for (const field of frame.fields) { + if (field.name === histogramFrameBucketMinFieldName) { + bucketMin = field; + } else if (field.name === histogramFrameBucketMaxFieldName) { + bucketMax = field; + } else if (field.type === FieldType.number) { + counts.push(field); + } + } + if (bucketMin && bucketMax && counts.length) { + return { + bucketMin, + bucketMax, + counts, + }; + } + return undefined; +} + +/** + * @alpha + */ +export function buildHistogram(frames: DataFrame[], options?: HistogramTransformerOptions): HistogramFields | null { + let bucketSize = options?.bucketSize; + let bucketOffset = options?.bucketOffset ?? 0; + + // if bucket size is auto, try to calc from all numeric fields + if (!bucketSize) { + let min = Infinity, + max = -Infinity; + + // TODO: include field configs! + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + for (const value of field.values.toArray()) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + } + } + + let range = Math.abs(max - min); + + // choose bucket + for (const size of histogramBucketSizes) { + if (range / 10 < size) { + bucketSize = size; + break; + } + } + } + + const getBucket = (v: number) => incrRoundDn(v - bucketOffset, bucketSize!) + bucketOffset; + + let histograms: AlignedData[] = []; + let counts: Field[] = []; + + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + let fieldHist = histogram(field.values.toArray(), getBucket, histFilter, histSort) as AlignedData; + histograms.push(fieldHist); + counts.push({ ...field }); + } + } + } + + // Quit early for empty a + if (!counts.length) { + return null; + } + + // align histograms + let joinedHists = join(histograms); + + // zero-fill all undefined values (missing buckets -> 0 counts) + for (let histIdx = 1; histIdx < joinedHists.length; histIdx++) { + let hist = joinedHists[histIdx]; + + for (let bucketIdx = 0; bucketIdx < hist.length; bucketIdx++) { + if (hist[bucketIdx] == null) { + hist[bucketIdx] = 0; + } + } + } + + const bucketMin = { + name: histogramFrameBucketMinFieldName, + values: new ArrayVector(joinedHists[0]), + type: FieldType.number, + config: {}, + }; + const bucketMax = { + name: histogramFrameBucketMaxFieldName, + values: new ArrayVector(joinedHists[0].map((v) => v + bucketSize!)), + type: FieldType.number, + config: {}, + }; + + if (options?.combine) { + const vals = new Array(joinedHists[0].length).fill(0); + for (let i = 1; i < joinedHists.length; i++) { + for (let j = 0; j < vals.length; j++) { + vals[j] += joinedHists[i][j]; + } + } + counts = [ + { + ...counts[0], + name: 'Count', + values: new ArrayVector(vals), + }, + ]; + } else { + counts.forEach((field, i) => { + field.values = new ArrayVector(joinedHists[i + 1]); + }); + } + + return { + bucketMin, + bucketMax, + counts, + }; +} + +// function incrRound(num: number, incr: number) { +// return Math.round(num / incr) * incr; +// } + +// function incrRoundUp(num: number, incr: number) { +// return Math.ceil(num / incr) * incr; +// } + +function incrRoundDn(num: number, incr: number) { + return Math.floor(num / incr) * incr; +} + +function histogram( + vals: number[], + getBucket: (v: number) => number, + filterOut?: any[] | null, + sort?: ((a: any, b: any) => number) | null +) { + let hist = new Map(); + + for (let i = 0; i < vals.length; i++) { + let v = vals[i]; + + if (v != null) { + v = getBucket(v); + } + + let entry = hist.get(v); + + if (entry) { + entry.count++; + } else { + hist.set(v, { value: v, count: 1 }); + } + } + + filterOut && filterOut.forEach((v) => hist.delete(v)); + + let bins = [...hist.values()]; + + sort && bins.sort((a, b) => sort(a.value, b.value)); + + let values = Array(bins.length); + let counts = Array(bins.length); + + for (let i = 0; i < bins.length; i++) { + values[i] = bins[i].value; + counts[i] = bins[i].count; + } + + return [values, counts]; +} + +/** + * @internal + */ +export function histogramFieldsToFrame(info: HistogramFields): DataFrame { + return { + fields: [info.bucketMin, info.bucketMax, ...info.counts], + length: info.bucketMin.values.length, + }; +} diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts new file mode 100644 index 0000000..f62da20 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -0,0 +1,26 @@ +export enum DataTransformerID { + // join = 'join', // Pick a field and merge all series based on that field + append = 'append', + // rotate = 'rotate', // Columns to rows + reduce = 'reduce', + order = 'order', + organize = 'organize', + rename = 'rename', + calculateField = 'calculateField', + seriesToColumns = 'seriesToColumns', + seriesToRows = 'seriesToRows', + merge = 'merge', + concatenate = 'concatenate', + labelsToFields = 'labelsToFields', + filterFields = 'filterFields', + filterFieldsByName = 'filterFieldsByName', + filterFrames = 'filterFrames', + filterByRefId = 'filterByRefId', + renameByRegex = 'renameByRegex', + filterByValue = 'filterByValue', + noop = 'noop', + ensureColumns = 'ensureColumns', + groupBy = 'groupBy', + sortBy = 'sortBy', + histogram = 'histogram', +} diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts new file mode 100644 index 0000000..5f6a5a1 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts @@ -0,0 +1,297 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { ArrayVector } from '../../vector'; +import { calculateFieldTransformer } from './calculateField'; +import { isLikelyAscendingVector, outerJoinDataFrames } from './joinDataFrames'; + +describe('align frames', () => { + beforeAll(() => { + mockTransformationsRegistry([calculateFieldTransformer]); + }); + + it('by first time field', () => { + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], + }); + + const series2 = toDataFrame({ + fields: [ + { name: '_time', type: FieldType.time, values: [1000, 1500, 2000] }, + { name: 'A', type: FieldType.number, values: [2, 20, 200] }, + { name: 'B', type: FieldType.number, values: [3, 30, 300] }, + { name: 'C', type: FieldType.string, values: ['first', 'second', 'third'] }, + ], + }); + + const out = outerJoinDataFrames({ frames: [series1, series2] })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "values": Array [ + 1000, + 1500, + 2000, + ], + }, + Object { + "name": "A", + "values": Array [ + 1, + undefined, + 100, + ], + }, + Object { + "name": "A", + "values": Array [ + 2, + 20, + 200, + ], + }, + Object { + "name": "B", + "values": Array [ + 3, + 30, + 300, + ], + }, + Object { + "name": "C", + "values": Array [ + "first", + "second", + "third", + ], + }, + ] + `); + }); + + it('unsorted input keep indexes', () => { + //---------- + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000, 1500] }, + { name: 'A1', type: FieldType.number, values: [1, 2, 15] }, + ], + }); + + const series3 = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [2000, 1000] }, + { name: 'A2', type: FieldType.number, values: [2, 1] }, + ], + }); + + let out = outerJoinDataFrames({ frames: [series1, series3], keepOriginIndices: true })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + state: f.state, + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "state": Object { + "origin": Object { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + "values": Array [ + 1000, + 1500, + 2000, + ], + }, + Object { + "name": "A1", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + "values": Array [ + 1, + 15, + 2, + ], + }, + Object { + "name": "A2", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 1, + }, + }, + "values": Array [ + 1, + undefined, + 2, + ], + }, + ] + `); + + // Fast path still adds origin indecies + out = outerJoinDataFrames({ frames: [series1], keepOriginIndices: true })!; + expect( + out.fields.map((f) => ({ + name: f.name, + state: f.state, + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "state": Object { + "origin": Object { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + }, + Object { + "name": "A1", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + }, + ] + `); + }); + + it('sort single frame', () => { + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [6000, 2000, 1500] }, + { name: 'A1', type: FieldType.number, values: [1, 22, 15] }, + ], + }); + + const out = outerJoinDataFrames({ frames: [series1], enforceSort: true, keepOriginIndices: true })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "values": Array [ + 1500, + 2000, + 6000, + ], + }, + Object { + "name": "A1", + "values": Array [ + 15, + 22, + 1, + ], + }, + ] + `); + }); + + it('supports duplicate times', () => { + //---------- + // NOTE!!! + // * ideally we would *keep* dupicate fields + //---------- + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], + }); + + const series3 = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 1000, 1000] }, + { name: 'A', type: FieldType.number, values: [2, 20, 200] }, + ], + }); + + const out = outerJoinDataFrames({ frames: [series1, series3] })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "values": Array [ + 1000, + 2000, + ], + }, + Object { + "name": "A", + "values": Array [ + 1, + 100, + ], + }, + Object { + "name": "A", + "values": Array [ + 200, + undefined, + ], + }, + ] + `); + }); + + describe('check ascending data', () => { + it('simple ascending', () => { + const v = new ArrayVector([1, 2, 3, 4, 5]); + expect(isLikelyAscendingVector(v)).toBeTruthy(); + }); + it('simple ascending with null', () => { + const v = new ArrayVector([null, 2, 3, 4, null]); + expect(isLikelyAscendingVector(v)).toBeTruthy(); + }); + it('single value', () => { + const v = new ArrayVector([null, null, null, 4, null]); + expect(isLikelyAscendingVector(v)).toBeTruthy(); + expect(isLikelyAscendingVector(new ArrayVector([4]))).toBeTruthy(); + expect(isLikelyAscendingVector(new ArrayVector([]))).toBeTruthy(); + }); + + it('middle values', () => { + const v = new ArrayVector([null, null, 5, 4, null]); + expect(isLikelyAscendingVector(v)).toBeFalsy(); + }); + + it('decending', () => { + expect(isLikelyAscendingVector(new ArrayVector([7, 6, null]))).toBeFalsy(); + expect(isLikelyAscendingVector(new ArrayVector([7, 8, 6]))).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts new file mode 100644 index 0000000..be0a764 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -0,0 +1,340 @@ +import { DataFrame, Field, FieldMatcher, FieldType, Vector } from '../../types'; +import { ArrayVector } from '../../vector'; +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from '../matchers/ids'; +import { getTimeField, sortDataFrame } from '../../dataframe'; + +export function pickBestJoinField(data: DataFrame[]): FieldMatcher { + const { timeField } = getTimeField(data[0]); + if (timeField) { + return fieldMatchers.get(FieldMatcherID.firstTimeField).get({}); + } + let common: string[] = []; + for (const f of data[0].fields) { + if (f.type === FieldType.number) { + common.push(f.name); + } + } + + for (let i = 1; i < data.length; i++) { + const names: string[] = []; + for (const f of data[0].fields) { + if (f.type === FieldType.number) { + names.push(f.name); + } + } + common = common.filter((v) => !names.includes(v)); + } + + return fieldMatchers.get(FieldMatcherID.byName).get(common[0]); +} + +/** + * @alpha + */ +export interface JoinOptions { + /** + * The input fields + */ + frames: DataFrame[]; + + /** + * The field to join -- frames that do not have this field will be droppped + */ + joinBy?: FieldMatcher; + + /** + * Optionally filter the non-join fields + */ + keep?: FieldMatcher; + + /** + * When the result is a single frame, this will to a quick check to see if the values are sorted, + * and sort if necessary. If the first/last values are in order the whole vector is assumed to be + * sorted + */ + enforceSort?: boolean; + + /** + * @internal -- used when we need to keep a reference to the original frame/field index + */ + keepOriginIndices?: boolean; +} + +function getJoinMatcher(options: JoinOptions): FieldMatcher { + return options.joinBy ?? pickBestJoinField(options.frames); +} + +/** + * This will return a single frame joined by the first matching field. When a join field is not specified, + * the default will use the first time field + */ +export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined { + if (!options.frames?.length) { + return; + } + + if (options.frames.length === 1) { + let frame = options.frames[0]; + let frameCopy = frame; + + if (options.keepOriginIndices) { + frameCopy = { + ...frame, + fields: frame.fields.map((f, fieldIndex) => { + const copy = { ...f }; + const origin = { + frameIndex: 0, + fieldIndex, + }; + if (copy.state) { + copy.state.origin = origin; + } else { + copy.state = { origin }; + } + return copy; + }), + }; + } + + const joinFieldMatcher = getJoinMatcher(options); + const joinIndex = frameCopy.fields.findIndex((f) => joinFieldMatcher(f, frameCopy, options.frames)); + + if (options.enforceSort) { + if (joinIndex >= 0) { + if (!isLikelyAscendingVector(frameCopy.fields[joinIndex].values)) { + frameCopy = sortDataFrame(frameCopy, joinIndex); + } + } + } + + if (options.keep) { + let fields = frameCopy.fields.filter( + (f, fieldIdx) => fieldIdx === joinIndex || options.keep!(f, frameCopy, options.frames) + ); + + // mutate already copied frame + if (frame !== frameCopy) { + frameCopy.fields = fields; + } else { + frameCopy = { + ...frame, + fields, + }; + } + } + + return frameCopy; + } + + const nullModes: JoinNullMode[][] = []; + const allData: AlignedData[] = []; + const originalFields: Field[] = []; + const joinFieldMatcher = getJoinMatcher(options); + + for (let frameIndex = 0; frameIndex < options.frames.length; frameIndex++) { + const frame = options.frames[frameIndex]; + + if (!frame || !frame.fields?.length) { + continue; // skip the frame + } + + const nullModesFrame: JoinNullMode[] = [NULL_REMOVE]; + let join: Field | undefined = undefined; + let fields: Field[] = []; + + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + field.state = field.state || {}; + + if (!join && joinFieldMatcher(field, frame, options.frames)) { + join = field; + } else { + if (options.keep && !options.keep(field, frame, options.frames)) { + continue; // skip field + } + + // Support the standard graph span nulls field config + nullModesFrame.push(field.config.custom?.spanNulls === true ? NULL_REMOVE : NULL_EXPAND); + + let labels = field.labels ?? {}; + if (frame.name) { + labels = { ...labels, name: frame.name }; + } + + fields.push({ + ...field, + labels, // add the name label from frame + }); + } + + if (options.keepOriginIndices) { + field.state.origin = { + frameIndex, + fieldIndex, + }; + } + } + + if (!join) { + continue; // skip the frame + } + + if (originalFields.length === 0) { + originalFields.push(join); // first join field + } + + nullModes.push(nullModesFrame); + const a: AlignedData = [join.values.toArray()]; // + + for (const field of fields) { + a.push(field.values.toArray()); + originalFields.push(field); + // clear field displayName state + delete field.state?.displayName; + } + + allData.push(a); + } + + const joined = join(allData, nullModes); + + return { + // ...options.data[0], // keep name, meta? + length: joined[0].length, + fields: originalFields.map((f, index) => ({ + ...f, + values: new ArrayVector(joined[index]), + })), + }; +} + +//-------------------------------------------------------------------------------- +// Below here is copied from uplot (MIT License) +// https://github.com/leeoniya/uPlot/blob/master/src/utils.js#L325 +// This avoids needing to import uplot into the data package +//-------------------------------------------------------------------------------- + +// Copied from uplot +export type AlignedData = [number[], ...Array>]; + +// nullModes +const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true) +const NULL_RETAIN = 1; // nulls are retained, with alignment artifacts set to undefined (default) +const NULL_EXPAND = 2; // nulls are expanded to include any adjacent alignment artifacts + +type JoinNullMode = number; // NULL_IGNORE | NULL_RETAIN | NULL_EXPAND; + +// sets undefined values to nulls when adjacent to existing nulls (minesweeper) +function nullExpand(yVals: Array, nullIdxs: number[], alignedLen: number) { + for (let i = 0, xi, lastNullIdx = -1; i < nullIdxs.length; i++) { + let nullIdx = nullIdxs[i]; + + if (nullIdx > lastNullIdx) { + xi = nullIdx - 1; + while (xi >= 0 && yVals[xi] == null) { + yVals[xi--] = null; + } + + xi = nullIdx + 1; + while (xi < alignedLen && yVals[xi] == null) { + yVals[(lastNullIdx = xi++)] = null; + } + } + } +} + +// nullModes is a tables-matched array indicating how to treat nulls in each series +export function join(tables: AlignedData[], nullModes?: number[][]) { + const xVals = new Set(); + + for (let ti = 0; ti < tables.length; ti++) { + let t = tables[ti]; + let xs = t[0]; + let len = xs.length; + + for (let i = 0; i < len; i++) { + xVals.add(xs[i]); + } + } + + let data = [Array.from(xVals).sort((a, b) => a - b)]; + + let alignedLen = data[0].length; + + let xIdxs = new Map(); + + for (let i = 0; i < alignedLen; i++) { + xIdxs.set(data[0][i], i); + } + + for (let ti = 0; ti < tables.length; ti++) { + let t = tables[ti]; + let xs = t[0]; + + for (let si = 1; si < t.length; si++) { + let ys = t[si]; + + let yVals = Array(alignedLen).fill(undefined); + + let nullMode = nullModes ? nullModes[ti][si] : NULL_RETAIN; + + let nullIdxs = []; + + for (let i = 0; i < ys.length; i++) { + let yVal = ys[i]; + let alignedIdx = xIdxs.get(xs[i]); + + if (yVal == null) { + if (nullMode !== NULL_REMOVE) { + yVals[alignedIdx] = yVal; + + if (nullMode === NULL_EXPAND) { + nullIdxs.push(alignedIdx); + } + } + } else { + yVals[alignedIdx] = yVal; + } + } + + nullExpand(yVals, nullIdxs, alignedLen); + + data.push(yVals); + } + } + + return data; +} + +// Quick test if the first and last points look to be ascending +// Only exported for tests +export function isLikelyAscendingVector(data: Vector): boolean { + let first: any = undefined; + + for (let idx = 0; idx < data.length; idx++) { + const v = data.get(idx); + if (v != null) { + if (first != null) { + if (first > v) { + return false; // descending + } + break; + } + first = v; + } + } + + let idx = data.length - 1; + while (idx >= 0) { + const v = data.get(idx--); + if (v != null) { + if (first > v) { + return false; + } + return true; + } + } + + return true; // only one non-null point +} diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts new file mode 100644 index 0000000..1f82865 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts @@ -0,0 +1,124 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { LabelsToFieldsOptions, labelsToFieldsTransformer } from './labelsToFields'; +import { DataTransformerConfig, FieldDTO, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { toDataFrame, toDataFrameDTO } from '../../dataframe'; +import { transformDataFrame } from '../transformDataFrame'; + +describe('Labels as Columns', () => { + beforeAll(() => { + mockTransformationsRegistry([labelsToFieldsTransformer]); + }); + + it('data frame with two labels', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: {}, + }; + + const source = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'Value', type: FieldType.number, values: [1, 2], labels: { location: 'inside', feelsLike: 'ok' } }, + ], + }); + + await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { + name: 'location', + type: FieldType.string, + values: ['inside', 'inside'], + config: {}, + }, + { name: 'feelsLike', type: FieldType.string, values: ['ok', 'ok'], config: {} }, + { name: 'Value', type: FieldType.number, values: [1, 2], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); + + it('data frame with two labels and valueLabel option', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: { valueLabel: 'name' }, + }; + + const source = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { + name: 'Value', + type: FieldType.number, + values: [1, 2], + labels: { location: 'inside', name: 'Request' }, + config: { + displayName: 'Custom1', + displayNameFromDS: 'Custom2', + }, + }, + ], + }); + + await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { + name: 'location', + type: FieldType.string, + values: ['inside', 'inside'], + config: {}, + }, + { name: 'Request', type: FieldType.number, values: [1, 2], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); + + it('two data frames with 1 value and 1 label', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: {}, + }; + + const oneValueOneLabelA = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: 'temp', type: FieldType.number, values: [1], labels: { location: 'inside' } }, + ], + }); + + const oneValueOneLabelB = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [2000] }, + { name: 'temp', type: FieldType.number, values: [-1], labels: { location: 'outside' } }, + ], + }); + + await expect(transformDataFrame([cfg], [oneValueOneLabelA, oneValueOneLabelB])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'location', type: FieldType.string, values: ['inside', 'outside'], config: {} }, + { name: 'temp', type: FieldType.number, values: [1, -1], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts new file mode 100644 index 0000000..67b718d --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts @@ -0,0 +1,76 @@ +import { map } from 'rxjs/operators'; + +import { DataFrame, DataTransformerInfo, Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { ArrayVector } from '../../vector'; +import { mergeTransformer } from './merge'; + +export interface LabelsToFieldsOptions { + /* + * If set this will use this label's value as the value field name. + */ + valueLabel?: string; +} + +export const labelsToFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.labelsToFields, + name: 'Labels to fields', + description: 'Extract time series labels to fields (columns)', + defaultOptions: {}, + operator: (options) => (source) => + source.pipe( + map((data) => { + const result: DataFrame[] = []; + + for (const frame of data) { + const newFields: Field[] = []; + + for (const field of frame.fields) { + if (!field.labels) { + newFields.push(field); + continue; + } + + let name = field.name; + + for (const labelName of Object.keys(field.labels)) { + // if we should use this label as the value field name store it and skip adding this as a separate field + if (options.valueLabel === labelName) { + name = field.labels[labelName]; + continue; + } + + const values = new Array(frame.length).fill(field.labels[labelName]); + newFields.push({ + name: labelName, + type: FieldType.string, + values: new ArrayVector(values), + config: {}, + }); + } + + // add the value field but clear out any labels or displayName + newFields.push({ + ...field, + name, + config: { + ...field.config, + // we need to clear thes for this transform as these can contain label names that we no longer want + displayName: undefined, + displayNameFromDS: undefined, + }, + labels: undefined, + }); + } + + result.push({ + fields: newFields, + length: frame.length, + }); + } + + return result; + }), + mergeTransformer.operator({}) + ), +}; diff --git a/packages/grafana-data/src/transformations/transformers/merge.test.ts b/packages/grafana-data/src/transformations/transformers/merge.test.ts new file mode 100644 index 0000000..a56c446 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/merge.test.ts @@ -0,0 +1,587 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, DisplayProcessor, Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe'; +import { transformDataFrame } from '../transformDataFrame'; +import { ArrayVector } from '../../vector'; +import { mergeTransformer, MergeTransformerOptions } from './merge'; + +describe('Merge multiple to single', () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.merge, + options: {}, + }; + + beforeAll(() => { + mockTransformationsRegistry([mergeTransformer]); + }); + + it('combine two series into one', async () => { + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [1000, 2000]), + createField('Temp', FieldType.number, [1, -1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two series with multiple values into one', async () => { + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine three series into one', async () => { + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const seriesC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [500] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB, seriesC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [1000, 2000, 500]), + createField('Temp', FieldType.number, [1, -1, 2]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine one serie and two tables into one table', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + { name: 'Humidity', type: FieldType.number, values: [10] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const tableB = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [500] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + { name: 'Humidity', type: FieldType.number, values: [5] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, seriesB, tableB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [1000, 1000, 500]), + createField('Temp', FieldType.number, [1, -1, 2]), + createField('Humidity', FieldType.number, [10, undefined, 5]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine one serie and two tables with ISO dates into one table', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: ['2019-10-01T11:10:23Z'] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + { name: 'Humidity', type: FieldType.number, values: [10] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: ['2019-09-01T11:10:23Z'] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const tableC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: ['2019-11-01T11:10:23Z'] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + { name: 'Humidity', type: FieldType.number, values: [5] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, seriesB, tableC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, ['2019-10-01T11:10:23Z', '2019-09-01T11:10:23Z', '2019-11-01T11:10:23Z']), + createField('Temp', FieldType.number, [1, -1, 2]), + createField('Humidity', FieldType.number, [10, undefined, 5]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two tables, where first is partial overlapping, into one', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { + name: 'Country', + type: FieldType.string, + values: ['United States', 'United States', 'Mexico', 'Germany', 'Canada', 'Canada'], + }, + { + name: 'AgeGroup', + type: FieldType.string, + values: ['50 or over', '35 - 49', '0 - 17', '35 - 49', '35 - 49', '25 - 34'], + }, + { name: 'Sum', type: FieldType.number, values: [998, 1193, 1675, 146, 166, 219] }, + ], + }); + + const tableB = toDataFrame({ + name: 'B', + fields: [ + { name: 'AgeGroup', type: FieldType.string, values: ['0 - 17', '18 - 24', '25 - 34', '35 - 49', '50 or over'] }, + { name: 'Count', type: FieldType.number, values: [1, 3, 2, 4, 2] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, tableB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Country', FieldType.string, [ + 'United States', + 'United States', + 'Mexico', + 'Germany', + 'Canada', + 'Canada', + undefined, + ]), + createField('AgeGroup', FieldType.string, [ + '50 or over', + '35 - 49', + '0 - 17', + '35 - 49', + '35 - 49', + '25 - 34', + '18 - 24', + ]), + createField('Sum', FieldType.number, [998, 1193, 1675, 146, 166, 219, undefined]), + createField('Count', FieldType.number, [2, 4, 1, 4, 4, 2, 3]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two tables, where second is partial overlapping, into one', async () => { + /** + * This behavior feels wrong. I would expect the same behavior regardless of the order + * of the frames. But when testing the old table panel it had this behavior so I am + * sticking with it. + */ + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'AgeGroup', type: FieldType.string, values: ['0 - 17', '18 - 24', '25 - 34', '35 - 49', '50 or over'] }, + { name: 'Count', type: FieldType.number, values: [1, 3, 2, 4, 2] }, + ], + }); + + const tableB = toDataFrame({ + name: 'B', + fields: [ + { + name: 'Country', + type: FieldType.string, + values: ['United States', 'United States', 'Mexico', 'Germany', 'Canada', 'Canada'], + }, + { + name: 'AgeGroup', + type: FieldType.string, + values: ['50 or over', '35 - 49', '0 - 17', '35 - 49', '35 - 49', '25 - 34'], + }, + { name: 'Sum', type: FieldType.number, values: [998, 1193, 1675, 146, 166, 219] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, tableB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('AgeGroup', FieldType.string, [ + '0 - 17', + '18 - 24', + '25 - 34', + '35 - 49', + '50 or over', + '35 - 49', + '35 - 49', + ]), + createField('Count', FieldType.number, [1, 3, 2, 4, 2, undefined, undefined]), + createField('Country', FieldType.string, [ + 'Mexico', + undefined, + 'Canada', + 'United States', + 'United States', + 'Germany', + 'Canada', + ]), + createField('Sum', FieldType.number, [1675, undefined, 219, 1193, 998, 146, 166]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine three tables with multiple values into one', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + { name: 'Humidity', type: FieldType.number, values: [10, 14, 55] }, + ], + }); + + const tableB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + { name: 'Enabled', type: FieldType.boolean, values: [true, false, true] }, + ], + }); + + const tableC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 124, 149] }, + { name: 'Humidity', type: FieldType.number, values: [22, 25, 30] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, tableB, tableC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126, 100, 124, 149]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3, 1, 4, 5]), + createField('Humidity', FieldType.number, [10, 14, 55, undefined, undefined, undefined, 22, 25, 30]), + createField('Enabled', FieldType.boolean, [ + undefined, + undefined, + undefined, + true, + false, + true, + undefined, + undefined, + undefined, + ]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has displayName, into one', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200], config: { displayName: 'Random time' } }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { displayName: 'Temp' } }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has display processor, into one', async () => { + const displayProcessor: DisplayProcessor = jest.fn(); + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200], display: displayProcessor }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126], {}, displayProcessor), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[0].display).toBe(displayProcessor); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has units, into one', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { units: 'celsius' } }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3], { units: 'celsius' }), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({ units: 'celsius' }); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where second serie fields has units, into one', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3], config: { units: 'celsius' } }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine one regular serie with an empty serie should return the regular serie', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200]), + createField('Temp', FieldType.number, [1, 4, 5]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine two regular series with an empty serie should return the combination of the regular series', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [], + }); + + const serieC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Humidity', type: FieldType.number, values: [6, 7, 8] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB, serieC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200]), + createField('Temp', FieldType.number, [1, 4, 5]), + createField('Humidity', FieldType.number, [6, 7, 8]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine multiple empty series should return one empty serie', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [], + }); + + const serieC = toDataFrame({ + name: 'C', + fields: [], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB, serieC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = []; + const fields = unwrap(result[0].fields); + + expect(fields).toEqual(expected); + expect(result.length).toEqual(1); + }); + }); +}); + +const createField = (name: string, type: FieldType, values: any[], config = {}, display?: DisplayProcessor): Field => { + return { name, type, values: new ArrayVector(values), config, labels: undefined, display }; +}; + +const unwrap = (fields: Field[]): Field[] => { + return fields.map((field) => + createField( + field.name, + field.type, + field.values.toArray().map((value: any) => value), + field.config, + field.display + ) + ); +}; diff --git a/packages/grafana-data/src/transformations/transformers/merge.ts b/packages/grafana-data/src/transformations/transformers/merge.ts new file mode 100644 index 0000000..ecc0e76 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/merge.ts @@ -0,0 +1,217 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { omit } from 'lodash'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { MutableDataFrame } from '../../dataframe'; + +interface ValuePointer { + key: string; + index: number; +} + +export interface MergeTransformerOptions {} + +export const mergeTransformer: DataTransformerInfo = { + id: DataTransformerID.merge, + name: 'Merge series/tables', + description: 'Merges multiple series/tables into a single serie/table', + defaultOptions: {}, + operator: (options) => (source) => + source.pipe( + map((dataFrames) => { + if (!Array.isArray(dataFrames) || dataFrames.length === 0) { + return dataFrames; + } + + const data = dataFrames.filter((frame) => frame.fields.length > 0); + + if (data.length === 0) { + return [dataFrames[0]]; + } + + const fieldNames = new Set(); + const fieldIndexByName: Record> = {}; + const fieldNamesForKey: string[] = []; + const dataFrame = new MutableDataFrame(); + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + + if (!fieldNames.has(field.name)) { + dataFrame.addField(copyFieldStructure(field)); + fieldNames.add(field.name); + } + + fieldIndexByName[field.name] = fieldIndexByName[field.name] || {}; + fieldIndexByName[field.name][frameIndex] = fieldIndex; + + if (data.length - 1 !== frameIndex) { + continue; + } + + if (fieldExistsInAllFrames(fieldIndexByName, field, data)) { + fieldNamesForKey.push(field.name); + } + } + } + + if (fieldNamesForKey.length === 0) { + return dataFrames; + } + + const valuesByKey: Record>> = {}; + const valuesInOrder: ValuePointer[] = []; + const keyFactory = createKeyFactory(data, fieldIndexByName, fieldNamesForKey); + const valueMapper = createValueMapper(data, fieldNames, fieldIndexByName); + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let valueIndex = 0; valueIndex < frame.length; valueIndex++) { + const key = keyFactory(frameIndex, valueIndex); + const value = valueMapper(frameIndex, valueIndex); + + if (!Array.isArray(valuesByKey[key])) { + valuesByKey[key] = [value]; + valuesInOrder.push(createPointer(key, valuesByKey)); + continue; + } + + let valueWasMerged = false; + + valuesByKey[key] = valuesByKey[key].map((existing) => { + if (!isMergable(existing, value)) { + return existing; + } + valueWasMerged = true; + return { ...existing, ...value }; + }); + + if (!valueWasMerged) { + valuesByKey[key].push(value); + valuesInOrder.push(createPointer(key, valuesByKey)); + } + } + } + + for (const pointer of valuesInOrder) { + const value = valuesByKey[pointer.key][pointer.index]; + + if (value) { + dataFrame.add(value); + } + } + + return [dataFrame]; + }) + ), +}; + +const copyFieldStructure = (field: Field): Field => { + return { + ...omit(field, ['values', 'state', 'labels', 'config']), + values: new ArrayVector(), + config: { + ...omit(field.config, 'displayName'), + }, + }; +}; + +const createKeyFactory = ( + data: DataFrame[], + fieldPointerByName: Record>, + keyFieldNames: string[] +) => { + const factoryIndex = keyFieldNames.reduce((index: Record, fieldName) => { + return Object.keys(fieldPointerByName[fieldName]).reduce((index: Record, frameIndex) => { + index[frameIndex] = index[frameIndex] || []; + index[frameIndex].push(fieldPointerByName[fieldName][frameIndex]); + return index; + }, index); + }, {}); + + return (frameIndex: number, valueIndex: number): string => { + return factoryIndex[frameIndex].reduce((key: string, fieldIndex: number) => { + return key + data[frameIndex].fields[fieldIndex].values.get(valueIndex); + }, ''); + }; +}; + +const createValueMapper = ( + data: DataFrame[], + fieldByName: Set, + fieldIndexByName: Record> +) => { + return (frameIndex: number, valueIndex: number) => { + const value: Record = {}; + const fieldNames = Array.from(fieldByName); + + for (const fieldName of fieldNames) { + const fieldIndexByFrameIndex = fieldIndexByName[fieldName]; + if (!fieldIndexByFrameIndex) { + continue; + } + + const fieldIndex = fieldIndexByFrameIndex[frameIndex]; + if (typeof fieldIndex !== 'number') { + continue; + } + + const frame = data[frameIndex]; + if (!frame || !frame.fields) { + continue; + } + + const field = frame.fields[fieldIndex]; + if (!field || !field.values) { + continue; + } + + value[fieldName] = field.values.get(valueIndex); + } + + return value; + }; +}; + +const isMergable = (existing: Record, value: Record): boolean => { + let mergable = true; + + for (const prop in value) { + if (typeof existing[prop] === 'undefined') { + continue; + } + + if (existing[prop] === null) { + continue; + } + + if (existing[prop] !== value[prop]) { + mergable = false; + break; + } + } + + return mergable; +}; + +const fieldExistsInAllFrames = ( + fieldIndexByName: Record>, + field: Field, + data: DataFrame[] +) => { + return Object.keys(fieldIndexByName[field.name]).length === data.length; +}; + +const createPointer = (key: string, valuesByKey: Record>>): ValuePointer => { + return { + key, + index: valuesByKey[key].length - 1, + }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/noop.ts b/packages/grafana-data/src/transformations/transformers/noop.ts new file mode 100644 index 0000000..32c6010 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/noop.ts @@ -0,0 +1,20 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; + +export interface NoopTransformerOptions { + include?: string; + exclude?: string; +} + +export const noopTransformer: DataTransformerInfo = { + id: DataTransformerID.noop, + name: 'noop', + description: 'No-operation transformer', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options: NoopTransformerOptions) => (source) => source, +}; diff --git a/packages/grafana-data/src/transformations/transformers/order.test.ts b/packages/grafana-data/src/transformations/transformers/order.test.ts new file mode 100644 index 0000000..c9e4ea7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/order.test.ts @@ -0,0 +1,182 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { orderFieldsTransformer, OrderFieldsTransformerOptions } from './order'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Order Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([orderFieldsTransformer]); + }); + describe('when consistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should order according to config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.order, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const ordered = data[0]; + expect(ordered.fields).toEqual([ + { + config: {}, + name: 'temperature', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + labels: undefined, + state: { + displayName: 'temperature', + }, + }, + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + labels: undefined, + state: { + displayName: 'humidity', + }, + }, + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + labels: undefined, + state: { + displayName: 'time', + }, + }, + ]); + }); + }); + }); + + describe('when inconsistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should append fields missing in config at the end', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.order, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const ordered = data[0]; + expect(ordered.fields).toEqual([ + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + labels: undefined, + state: { + displayName: 'humidity', + }, + }, + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + labels: undefined, + state: { + displayName: 'time', + }, + }, + { + config: {}, + name: 'pressure', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + labels: undefined, + state: { + displayName: 'pressure', + }, + }, + ]); + }); + }); + }); + + describe('when transforming with empty configuration', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should keep the same order as in the incoming data', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.order, + options: { + indexByName: {}, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const ordered = data[0]; + expect(ordered.fields).toEqual([ + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + name: 'pressure', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/order.ts b/packages/grafana-data/src/transformations/transformers/order.ts new file mode 100644 index 0000000..d1b48dd --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/order.ts @@ -0,0 +1,64 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field } from '../../types'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { map } from 'rxjs/operators'; + +export interface OrderFieldsTransformerOptions { + indexByName: Record; +} + +export const orderFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.order, + name: 'Order fields by name', + description: 'Order fields based on configuration given by user', + defaultOptions: { + indexByName: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const orderer = createFieldsOrderer(options.indexByName); + + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + return data.map((frame) => ({ + ...frame, + fields: orderer(frame.fields, data, frame), + })); + }) + ), +}; + +export const createOrderFieldsComparer = (indexByName: Record) => (a: string, b: string) => { + return indexOfField(a, indexByName) - indexOfField(b, indexByName); +}; + +const createFieldsOrderer = (indexByName: Record) => ( + fields: Field[], + data: DataFrame[], + frame: DataFrame +) => { + if (!Array.isArray(fields) || fields.length === 0) { + return fields; + } + if (!indexByName || Object.keys(indexByName).length === 0) { + return fields; + } + const comparer = createOrderFieldsComparer(indexByName); + return fields.sort((a, b) => comparer(getFieldDisplayName(a, frame, data), getFieldDisplayName(b, frame, data))); +}; + +const indexOfField = (fieldName: string, indexByName: Record) => { + if (Number.isInteger(indexByName[fieldName])) { + return indexByName[fieldName]; + } + return Number.MAX_SAFE_INTEGER; +}; diff --git a/packages/grafana-data/src/transformations/transformers/organize.test.ts b/packages/grafana-data/src/transformations/transformers/organize.test.ts new file mode 100644 index 0000000..b0dff65 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/organize.test.ts @@ -0,0 +1,134 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { organizeFieldsTransformer, OrganizeFieldsTransformerOptions } from './organize'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('OrganizeFields Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([organizeFieldsTransformer]); + }); + + describe('when consistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should order and filter according to config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.organize, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + excludeByName: { + time: true, + }, + renameByName: { + humidity: 'renamed_humidity', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const organized = data[0]; + expect(organized.fields).toEqual([ + { + config: {}, + labels: undefined, + name: 'temperature', + state: { + displayName: 'temperature', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: { + displayName: 'renamed_humidity', + }, + labels: undefined, + name: 'humidity', + state: { + displayName: 'renamed_humidity', + }, + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); + + describe('when inconsistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should append fields missing in config at the end', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.organize, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + excludeByName: { + humidity: true, + }, + renameByName: { + time: 'renamed_time', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const organized = data[0]; + expect(organized.fields).toEqual([ + { + labels: undefined, + config: { + displayName: 'renamed_time', + }, + name: 'time', + state: { + displayName: 'renamed_time', + }, + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + labels: undefined, + name: 'pressure', + state: { + displayName: 'pressure', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + ]); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/organize.ts b/packages/grafana-data/src/transformations/transformers/organize.ts new file mode 100644 index 0000000..7913cdb --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/organize.ts @@ -0,0 +1,43 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { orderFieldsTransformer, OrderFieldsTransformerOptions } from './order'; +import { filterFieldsByNameTransformer } from './filterByName'; +import { renameFieldsTransformer, RenameFieldsTransformerOptions } from './rename'; + +export interface OrganizeFieldsTransformerOptions + extends OrderFieldsTransformerOptions, + RenameFieldsTransformerOptions { + excludeByName: Record; +} + +export const organizeFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.organize, + name: 'Organize fields by name', + description: 'Order, filter and rename fields based on configuration given by user', + defaultOptions: { + excludeByName: {}, + indexByName: {}, + renameByName: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + filterFieldsByNameTransformer.operator({ + exclude: { names: mapToExcludeArray(options.excludeByName) }, + }), + orderFieldsTransformer.operator(options), + renameFieldsTransformer.operator(options) + ), +}; + +const mapToExcludeArray = (excludeByName: Record): string[] => { + if (!excludeByName) { + return []; + } + + return Object.keys(excludeByName).filter((name) => excludeByName[name]); +}; diff --git a/packages/grafana-data/src/transformations/transformers/reduce.test.ts b/packages/grafana-data/src/transformations/transformers/reduce.test.ts new file mode 100644 index 0000000..b14d1d5 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/reduce.test.ts @@ -0,0 +1,369 @@ +import { ReducerID } from '../fieldReducer'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { reduceFields, reduceTransformer } from './reduce'; +import { transformDataFrame } from '../transformDataFrame'; +import { Field, FieldType } from '../../types'; +import { ArrayVector } from '../../vector'; +import { notTimeFieldMatcher } from '../matchers/predicates'; +import { DataFrameView } from '../../dataframe'; + +const seriesAWithSingleField = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] }, + ], +}); + +const seriesAWithMultipleFields = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], +}); + +const seriesBWithSingleField = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] }, + { name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] }, + ], +}); + +const seriesBWithMultipleFields = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] }, + { name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] }, + { name: 'humidity', type: FieldType.number, values: [11000.1, 11000.3, 11000.5, 11000.7] }, + ], +}); + +describe('Reducer Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([reduceTransformer]); + }); + + it('reduces multiple data frames with many fields', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithMultipleFields, seriesBWithMultipleFields])).toEmitValuesWith( + (received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature', 'A humidity', 'B temperature', 'B humidity']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3, 10000.3, 1, 11000.1]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3, 10000.3, 1, 11000.1]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10000.6, 7, 11000.7]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6, 10000.6, 7, 11000.7]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(4); + expect(processed[0].fields).toEqual(expected); + } + ); + }); + + it('reduces multiple data frames with single field', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField, seriesBWithSingleField])).toEmitValuesWith( + (received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature', 'B temperature']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3, 1]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3, 1]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 7]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6, 7]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + } + ); + }); + + it('reduces single data frame with many fields', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithMultipleFields])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature', 'A humidity']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3, 10000.3]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3, 10000.3]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10000.6]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6, 10000.6]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('reduces single data frame with single field', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(1); + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('reduces fields with single calculator', () => { + const frames = reduceFields( + [seriesAWithSingleField, seriesAWithMultipleFields], // data + notTimeFieldMatcher, // skip time fields + [ReducerID.last] // only one + ); + + // Convert each frame to a structure with the same fields + expect(frames.length).toEqual(2); + expect(frames[0].length).toEqual(1); + expect(frames[1].length).toEqual(1); + + const view0 = new DataFrameView(frames[0]); + const view1 = new DataFrameView(frames[1]); + expect({ ...view0.get(0) }).toMatchInlineSnapshot(` + Object { + "temperature": 6, + } + `); + expect({ ...view1.get(0) }).toMatchInlineSnapshot(` + Object { + "humidity": 10000.6, + "temperature": 6, + } + `); + }); + + it('reduces multiple data frames with decimal display name (https://github.com/grafana/grafana/issues/31580)', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.max], + }, + }; + + const seriesA = toDataFrame({ + name: 'a', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'value', type: FieldType.number, values: [3, 4, 5, 6], state: { displayName: 'a' } }, + ], + }); + + const seriesB = toDataFrame({ + name: '2021', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'value', type: FieldType.number, values: [7, 8, 9, 10], state: { displayName: '2021' } }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['a', '2021']), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('reduces multiple data frames with decimal fields name (https://github.com/grafana/grafana/issues/31580)', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.max], + }, + }; + + const seriesA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'a', type: FieldType.number, values: [3, 4, 5, 6] }, + ], + }); + + const seriesB = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: '2021', type: FieldType.number, values: [7, 8, 9, 10] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['a', '2021']), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/reduce.ts b/packages/grafana-data/src/transformations/transformers/reduce.ts new file mode 100644 index 0000000..413f6c5 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/reduce.ts @@ -0,0 +1,216 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, FieldMatcher, MatcherConfig } from '../../types/transformations'; +import { fieldReducers, reduceField, ReducerID } from '../fieldReducer'; +import { alwaysFieldMatcher, notTimeFieldMatcher } from '../matchers/predicates'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { KeyValue } from '../../types/data'; +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { getFieldMatcher } from '../matchers'; +import { getFieldDisplayName } from '../../field'; + +export enum ReduceTransformerMode { + SeriesToRows = 'seriesToRows', // default + ReduceFields = 'reduceFields', // same structure, add additional row for each type +} +export interface ReduceTransformerOptions { + reducers: ReducerID[]; + fields?: MatcherConfig; // Assume all fields + mode?: ReduceTransformerMode; + includeTimeField?: boolean; +} + +export const reduceTransformer: DataTransformerInfo = { + id: DataTransformerID.reduce, + name: 'Reduce', + description: 'Reduce all rows or data points to a single value using a function like max, min, mean or last', + defaultOptions: { + reducers: [ReducerID.max], + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!options?.reducers?.length) { + return data; // nothing selected + } + + const matcher = options.fields + ? getFieldMatcher(options.fields) + : options.includeTimeField && options.mode === ReduceTransformerMode.ReduceFields + ? alwaysFieldMatcher + : notTimeFieldMatcher; + + // Collapse all matching fields into a single row + if (options.mode === ReduceTransformerMode.ReduceFields) { + return reduceFields(data, matcher, options.reducers); + } + + // Add a row for each series + const res = reduceSeriesToRows(data, matcher, options.reducers); + return res ? [res] : []; + }) + ), +}; + +/** + * @internal only exported for testing + */ +export function reduceSeriesToRows( + data: DataFrame[], + matcher: FieldMatcher, + reducerId: ReducerID[] +): DataFrame | undefined { + const calculators = fieldReducers.list(reducerId); + const reducers = calculators.map((c) => c.id); + const processed: DataFrame[] = []; + + for (const series of data) { + const values: ArrayVector[] = []; + const fields: Field[] = []; + const byId: KeyValue = {}; + + values.push(new ArrayVector()); // The name + fields.push({ + name: 'Field', + type: FieldType.string, + values: values[0], + config: {}, + }); + + for (const info of calculators) { + const vals = new ArrayVector(); + byId[info.id] = vals; + values.push(vals); + + fields.push({ + name: info.name, + type: FieldType.other, // UNKNOWN until after we call the functions + values: values[values.length - 1], + config: {}, + }); + } + + for (let i = 0; i < series.fields.length; i++) { + const field = series.fields[i]; + + if (matcher(field, series, data)) { + const results = reduceField({ + field, + reducers, + }); + + // Update the name list + const fieldName = getFieldDisplayName(field, series, data); + + values[0].buffer.push(fieldName); + + for (const info of calculators) { + const v = results[info.id]; + byId[info.id].buffer.push(v); + } + } + } + + for (const f of fields) { + const t = guessFieldTypeForField(f); + + if (t) { + f.type = t; + } + } + + processed.push({ + ...series, // Same properties, different fields + fields, + length: values[0].length, + }); + } + + return mergeResults(processed); +} + +/** + * @internal only exported for testing + */ +export function mergeResults(data: DataFrame[]): DataFrame | undefined { + if (!data?.length) { + return undefined; + } + + const baseFrame = data[0]; + + for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) { + const series = data[seriesIndex]; + + for (let baseIndex = 0; baseIndex < baseFrame.fields.length; baseIndex++) { + const baseField = baseFrame.fields[baseIndex]; + for (let fieldIndex = 0; fieldIndex < series.fields.length; fieldIndex++) { + const field = series.fields[fieldIndex]; + const isFirstField = baseIndex === 0 && fieldIndex === 0; + const isSameField = baseField.type === field.type && baseField.name === field.name; + + if (isFirstField || isSameField) { + const baseValues: any[] = baseField.values.toArray(); + const values: any[] = field.values.toArray(); + ((baseField.values as unknown) as ArrayVector).buffer = baseValues.concat(values); + } + } + } + } + + baseFrame.name = undefined; + baseFrame.length = baseFrame.fields[0].values.length; + return baseFrame; +} + +/** + * @internal -- only exported for testing + */ +export function reduceFields(data: DataFrame[], matcher: FieldMatcher, reducerId: ReducerID[]): DataFrame[] { + const calculators = fieldReducers.list(reducerId); + const reducers = calculators.map((c) => c.id); + const processed: DataFrame[] = []; + + for (const series of data) { + const fields: Field[] = []; + for (const field of series.fields) { + if (matcher(field, series, data)) { + const results = reduceField({ + field, + reducers, + }); + for (const reducer of reducers) { + const value = results[reducer]; + const copy = { + ...field, + values: new ArrayVector([value]), + }; + copy.state = undefined; + if (reducers.length > 1) { + if (!copy.labels) { + copy.labels = {}; + } + copy.labels['reducer'] = fieldReducers.get(reducer).name; + } + fields.push(copy); + } + } + } + if (fields.length) { + processed.push({ + ...series, + fields, + length: 1, // always one row + }); + } + } + + return processed; +} diff --git a/packages/grafana-data/src/transformations/transformers/rename.test.ts b/packages/grafana-data/src/transformations/transformers/rename.test.ts new file mode 100644 index 0000000..641ab9b --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/rename.test.ts @@ -0,0 +1,193 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { renameFieldsTransformer, RenameFieldsTransformerOptions } from './rename'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Rename Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([renameFieldsTransformer]); + }); + + describe('when consistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should rename according to config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.rename, + options: { + renameByName: { + time: 'Total time', + humidity: 'Moistness', + temperature: 'how cold is it?', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const renamed = data[0]; + expect(renamed.fields).toEqual([ + { + config: { + displayName: 'Total time', + }, + labels: undefined, + name: 'time', + state: { + displayName: 'Total time', + }, + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: { + displayName: 'how cold is it?', + }, + labels: undefined, + name: 'temperature', + state: { + displayName: 'how cold is it?', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: { + displayName: 'Moistness', + }, + name: 'humidity', + labels: undefined, + state: { + displayName: 'Moistness', + }, + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); + + describe('when inconsistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should not rename fields missing in config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.rename, + options: { + renameByName: { + time: 'ttl', + temperature: 'temp', + humidity: 'hum', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const renamed = data[0]; + expect(renamed.fields).toEqual([ + { + config: { + displayName: 'ttl', + }, + name: 'time', + labels: undefined, + state: { + displayName: 'ttl', + }, + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + labels: undefined, + name: 'pressure', + state: { + displayName: 'pressure', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: { + displayName: 'hum', + }, + labels: undefined, + name: 'humidity', + state: { + displayName: 'hum', + }, + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); + + describe('when transforming with empty configuration', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should keep the same names as in the incoming data', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.rename, + options: { + renameByName: {}, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const renamed = data[0]; + expect(renamed.fields).toEqual([ + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + name: 'pressure', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/rename.ts b/packages/grafana-data/src/transformations/transformers/rename.ts new file mode 100644 index 0000000..0f22557 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/rename.ts @@ -0,0 +1,65 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { map } from 'rxjs/operators'; + +export interface RenameFieldsTransformerOptions { + renameByName: Record; +} + +export const renameFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.rename, + name: 'Rename fields by name', + description: 'Rename fields based on configuration given by user', + defaultOptions: { + renameByName: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const renamer = createRenamer(options.renameByName); + + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + return data.map((frame) => ({ + ...frame, + fields: renamer(frame), + })); + }) + ), +}; + +const createRenamer = (renameByName: Record) => (frame: DataFrame): Field[] => { + if (!renameByName || Object.keys(renameByName).length === 0) { + return frame.fields; + } + + return frame.fields.map((field) => { + const displayName = getFieldDisplayName(field, frame); + const renameTo = renameByName[displayName]; + + if (typeof renameTo !== 'string' || renameTo.length === 0) { + return field; + } + + return { + ...field, + config: { + ...field.config, + displayName: renameTo, + }, + state: { + ...field.state, + displayName: renameTo, + }, + }; + }); +}; diff --git a/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts b/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts new file mode 100644 index 0000000..2c4cac8 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts @@ -0,0 +1,180 @@ +import { DataTransformerConfig, DataTransformerID, FieldType, toDataFrame, transformDataFrame } from '@grafana/data'; +import { renameByRegexTransformer, RenameByRegexTransformerOptions } from './renameByRegex'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Rename By Regex Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([renameByRegexTransformer]); + }); + + describe('when regex and replacement pattern', () => { + const data = toDataFrame({ + name: 'web-01.example.com', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: { name: 'Time' }, + values: [3000, 4000, 5000, 6000], + }, + { + name: 'Value', + type: FieldType.number, + config: { displayName: 'web-01.example.com' }, + values: [10000.3, 10000.4, 10000.5, 10000.6], + }, + ], + }); + + it('should rename matches using references', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.renameByRegex, + options: { + regex: '([^.]+).example.com', + renamePattern: '$1', + }, + }; + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const frame = data[0]; + expect(frame.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "name": "Time", + }, + "name": "Time", + "state": Object { + "displayName": "Time", + }, + "type": "time", + "values": Array [ + 3000, + 4000, + 5000, + 6000, + ], + }, + Object { + "config": Object { + "displayName": "web-01", + }, + "name": "Value", + "state": Object { + "displayName": "web-01", + }, + "type": "number", + "values": Array [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + ], + }, + ] + `); + }); + }); + + it('should not rename misses', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.renameByRegex, + options: { + regex: '([^.]+).bad-domain.com', + renamePattern: '$1', + }, + }; + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const frame = data[0]; + expect(frame.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "name": "Time", + }, + "name": "Time", + "state": Object { + "displayName": "Time", + }, + "type": "time", + "values": Array [ + 3000, + 4000, + 5000, + 6000, + ], + }, + Object { + "config": Object { + "displayName": "web-01.example.com", + }, + "name": "Value", + "state": Object { + "displayName": "web-01.example.com", + }, + "type": "number", + "values": Array [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + ], + }, + ] + `); + }); + }); + + it('should not rename with empty regex and repacement pattern', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.renameByRegex, + options: { + regex: '', + renamePattern: '', + }, + }; + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const frame = data[0]; + expect(frame.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "displayName": "Time", + "name": "Time", + }, + "name": "Time", + "state": Object { + "displayName": "Time", + }, + "type": "time", + "values": Array [ + 3000, + 4000, + 5000, + 6000, + ], + }, + Object { + "config": Object { + "displayName": "web-01.example.com", + }, + "name": "Value", + "state": Object { + "displayName": "web-01.example.com", + }, + "type": "number", + "values": Array [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + ], + }, + ] + `); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/renameByRegex.ts b/packages/grafana-data/src/transformations/transformers/renameByRegex.ts new file mode 100644 index 0000000..be93f52 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/renameByRegex.ts @@ -0,0 +1,62 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { map } from 'rxjs/operators'; +import { DataFrame } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; + +/** + * Options for renameByRegexTransformer + * + * @public + */ +export interface RenameByRegexTransformerOptions { + regex: string; + renamePattern: string; +} + +/** + * Replaces the displayName of a field by applying a regular expression + * to match the name and a pattern for the replacement. + * + * @public + */ +export const renameByRegexTransformer: DataTransformerInfo = { + id: DataTransformerID.renameByRegex, + name: 'Rename fields by regex', + description: 'Rename fields based on regular expression by users.', + defaultOptions: { + regex: '(.*)', + renamePattern: '$1', + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + return data.map(renameFieldsByRegex(options)); + }) + ), +}; + +const renameFieldsByRegex = (options: RenameByRegexTransformerOptions) => (frame: DataFrame) => { + const regex = new RegExp(options.regex); + const fields = frame.fields.map((field) => { + const displayName = getFieldDisplayName(field, frame); + if (!regex.test(displayName)) { + return field; + } + const newDisplayName = displayName.replace(regex, options.renamePattern); + return { + ...field, + config: { ...field.config, displayName: newDisplayName }, + state: { ...field.state, displayName: newDisplayName }, + }; + }); + return { ...frame, fields }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts b/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts new file mode 100644 index 0000000..d97edad --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts @@ -0,0 +1,488 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { SeriesToColumnsOptions, seriesToColumnsTransformer } from './seriesToColumns'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('SeriesToColumns Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([seriesToColumnsTransformer]); + }); + + const everySecondSeries = toDataFrame({ + name: 'even', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + const everyOtherSecondSeries = toDataFrame({ + name: 'odd', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] }, + { name: 'temperature', type: FieldType.number, values: [11.1, 11.3, 11.5, 11.7] }, + { name: 'humidity', type: FieldType.number, values: [11000.1, 11000.3, 11000.5, 11000.7] }, + ], + }); + + it('joins by time field', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1000, + 3000, + 4000, + 5000, + 6000, + 7000, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10.3, + 10.4, + 10.5, + 10.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 11.1, + 11.3, + undefined, + 11.5, + undefined, + 11.7, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + 11000.1, + 11000.3, + undefined, + 11000.5, + undefined, + 11000.7, + ], + }, + ] + `); + } + ); + }); + + it('joins by temperature field', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'temperature', + }, + }; + + await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(`Array []`); + } + ); + }); + + it('joins by time field in reverse order', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + everySecondSeries.fields[0].values = new ArrayVector(everySecondSeries.fields[0].values.toArray().reverse()); + everySecondSeries.fields[1].values = new ArrayVector(everySecondSeries.fields[1].values.toArray().reverse()); + everySecondSeries.fields[2].values = new ArrayVector(everySecondSeries.fields[2].values.toArray().reverse()); + + await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1000, + 3000, + 4000, + 5000, + 6000, + 7000, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10.3, + 10.4, + 10.5, + 10.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 11.1, + 11.3, + undefined, + 11.5, + undefined, + 11.7, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + 11000.1, + 11000.3, + undefined, + 11000.5, + undefined, + 11000.7, + ], + }, + ] + `); + } + ); + }); + + describe('Field names', () => { + const seriesWithSameFieldAndDataFrameName = toDataFrame({ + name: 'temperature', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'temperature', type: FieldType.number, values: [2, 4, 6, 8] }, + ], + }); + + it('when dataframe and field share the same name then use the field name', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithSameFieldAndDataFrameName, seriesB])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1000, + 2000, + 3000, + 4000, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "temperature", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 1, + 3, + 5, + 7, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "B", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 2, + 4, + 6, + 8, + ], + }, + ] + `); + } + ); + }); + }); + + it('joins if fields are missing', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + const frame1 = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'temperature', type: FieldType.number, values: [10, 11, 12] }, + ], + }); + + const frame2 = toDataFrame({ + name: 'B', + fields: [], + }); + + const frame3 = toDataFrame({ + name: 'C', + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'temperature', type: FieldType.number, values: [20, 22, 24] }, + ], + }); + + await expect(transformDataFrame([cfg], [frame1, frame2, frame3])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1, + 2, + 3, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "A", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 10, + 11, + 12, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "C", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 20, + 22, + 24, + ], + }, + ] + `); + }); + }); + + it('handles duplicate field name', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + const frame1 = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1] }, + { name: 'temperature', type: FieldType.number, values: [10] }, + ], + }); + + const frame2 = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1] }, + { name: 'temperature', type: FieldType.number, values: [20] }, + ], + }); + + await expect(transformDataFrame([cfg], [frame1, frame2])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 10, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 20, + ], + }, + ] + `); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/seriesToColumns.ts b/packages/grafana-data/src/transformations/transformers/seriesToColumns.ts new file mode 100644 index 0000000..07acaf8 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToColumns.ts @@ -0,0 +1,36 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerInfo, FieldMatcher } from '../../types'; +import { DataTransformerID } from './ids'; +import { outerJoinDataFrames } from './joinDataFrames'; +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from '../matchers/ids'; + +export interface SeriesToColumnsOptions { + byField?: string; // empty will pick the field automatically +} + +export const seriesToColumnsTransformer: DataTransformerInfo = { + id: DataTransformerID.seriesToColumns, + name: 'Series as columns', // Called 'Outer join' in the UI! + description: 'Groups series by field and returns values as columns', + defaultOptions: { + byField: undefined, // DEFAULT_KEY_FIELD, + }, + operator: (options) => (source) => + source.pipe( + map((data) => { + if (data.length > 1) { + let joinBy: FieldMatcher | undefined = undefined; + if (options.byField) { + joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField); + } + const joined = outerJoinDataFrames({ frames: data, joinBy }); + if (joined) { + return [joined]; + } + } + return data; + }) + ), +}; diff --git a/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts b/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts new file mode 100644 index 0000000..bb43826 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts @@ -0,0 +1,260 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe'; +import { transformDataFrame } from '../transformDataFrame'; +import { ArrayVector } from '../../vector'; +import { seriesToRowsTransformer, SeriesToRowsTransformerOptions } from './seriesToRows'; + +describe('Series to rows', () => { + beforeAll(() => { + mockTransformationsRegistry([seriesToRowsTransformer]); + }); + + it('combine two series into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [2000, 1000]), + createField('Metric', FieldType.string, ['B', 'A']), + createField('Value', FieldType.number, [-1, 1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two series with multiple values into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine three series into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const seriesC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [500] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB, seriesC])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [2000, 1000, 500]), + createField('Metric', FieldType.string, ['B', 'A', 'C']), + createField('Value', FieldType.number, [-1, 1, 2]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has displayName, into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200], config: { displayName: 'Random time' } }, + { + name: 'Temp', + type: FieldType.number, + values: [1, 4, 5], + config: { displayName: 'Temp', displayNameFromDS: 'dsName' }, + }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[2].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has units, into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { units: 'celsius' } }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1], { units: 'celsius' }), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[2].config).toEqual({ units: 'celsius' }); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where second serie fields has units, into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3], config: { units: 'celsius' } }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[2].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); +}); + +const createField = (name: string, type: FieldType, values: any[], config = {}): Field => { + return { name, type, values: new ArrayVector(values), config, labels: undefined }; +}; + +const unwrap = (fields: Field[]): Field[] => { + return fields.map((field) => + createField( + field.name, + field.type, + field.values.toArray().map((value: any) => value), + field.config + ) + ); +}; diff --git a/packages/grafana-data/src/transformations/transformers/seriesToRows.ts b/packages/grafana-data/src/transformations/transformers/seriesToRows.ts new file mode 100644 index 0000000..a2ddb11 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToRows.ts @@ -0,0 +1,99 @@ +import { omit } from 'lodash'; +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { + Field, + FieldType, + TIME_SERIES_METRIC_FIELD_NAME, + TIME_SERIES_TIME_FIELD_NAME, + TIME_SERIES_VALUE_FIELD_NAME, +} from '../../types/dataFrame'; +import { isTimeSeries } from '../../dataframe/utils'; +import { MutableDataFrame, sortDataFrame } from '../../dataframe'; +import { ArrayVector } from '../../vector'; +import { getFrameDisplayName } from '../../field/fieldState'; + +export interface SeriesToRowsTransformerOptions {} + +export const seriesToRowsTransformer: DataTransformerInfo = { + id: DataTransformerID.seriesToRows, + name: 'Series to rows', + description: 'Combines multiple series into a single serie and appends a column with metric name per value.', + defaultOptions: {}, + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length <= 1) { + return data; + } + + if (!isTimeSeries(data)) { + return data; + } + + const timeFieldByIndex: Record = {}; + const targetFields = new Set(); + const dataFrame = new MutableDataFrame(); + const metricField: Field = { + name: TIME_SERIES_METRIC_FIELD_NAME, + values: new ArrayVector(), + config: {}, + type: FieldType.string, + }; + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + + if (field.type === FieldType.time) { + timeFieldByIndex[frameIndex] = fieldIndex; + + if (!targetFields.has(TIME_SERIES_TIME_FIELD_NAME)) { + dataFrame.addField(copyFieldStructure(field, TIME_SERIES_TIME_FIELD_NAME)); + dataFrame.addField(metricField); + targetFields.add(TIME_SERIES_TIME_FIELD_NAME); + } + continue; + } + + if (!targetFields.has(TIME_SERIES_VALUE_FIELD_NAME)) { + dataFrame.addField(copyFieldStructure(field, TIME_SERIES_VALUE_FIELD_NAME)); + targetFields.add(TIME_SERIES_VALUE_FIELD_NAME); + } + } + } + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let valueIndex = 0; valueIndex < frame.length; valueIndex++) { + const timeFieldIndex = timeFieldByIndex[frameIndex]; + const valueFieldIndex = timeFieldIndex === 0 ? 1 : 0; + + dataFrame.add({ + [TIME_SERIES_TIME_FIELD_NAME]: frame.fields[timeFieldIndex].values.get(valueIndex), + [TIME_SERIES_METRIC_FIELD_NAME]: getFrameDisplayName(frame), + [TIME_SERIES_VALUE_FIELD_NAME]: frame.fields[valueFieldIndex].values.get(valueIndex), + }); + } + } + + return [sortDataFrame(dataFrame, 0, true)]; + }) + ), +}; + +const copyFieldStructure = (field: Field, name: string): Field => { + return { + ...omit(field, ['values', 'state', 'labels', 'config', 'name']), + name: name, + values: new ArrayVector(), + config: { + ...omit(field.config, ['displayName', 'displayNameFromDS']), + }, + }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.test.ts b/packages/grafana-data/src/transformations/transformers/sortBy.test.ts new file mode 100644 index 0000000..534bd56 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/sortBy.test.ts @@ -0,0 +1,99 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { sortByTransformer, SortByTransformerOptions } from './sortBy'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { DataTransformerConfig } from '@grafana/data'; + +const testFrame = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] }, // desc + { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c'] }, + { name: 'count', type: FieldType.string, values: [1, 2, 3, 4, 5] }, // asc + ], +}); + +describe('SortBy transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([sortByTransformer]); + }); + + it('should not apply transformation if config is missing sort fields', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.sortBy, + options: { + sort: [], // nothing + }, + }; + + await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0]).toBe(testFrame); + }); + }); + + it('should sort time asc', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.sortBy, + options: { + sort: [ + { + field: 'time', + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { + expect(getFieldSnapshot(received[0][0].fields[0])).toMatchInlineSnapshot(` + Object { + "name": "time", + "values": Array [ + 5, + 6, + 7, + 8, + 9, + 10, + ], + } + `); + }); + }); + + it('should sort time (desc)', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.sortBy, + options: { + sort: [ + { + field: 'time', + desc: true, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { + expect(getFieldSnapshot(received[0][0].fields[0])).toMatchInlineSnapshot(` + Object { + "name": "time", + "values": Array [ + 10, + 9, + 8, + 7, + 6, + 5, + ], + } + `); + }); + }); +}); + +function getFieldSnapshot(f: Field): Object { + return { name: f.name, values: f.values.toArray() }; +} diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.ts b/packages/grafana-data/src/transformations/transformers/sortBy.ts new file mode 100644 index 0000000..2e37729 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/sortBy.ts @@ -0,0 +1,65 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame } from '../../types'; +import { getFieldDisplayName } from '../../field'; +import { sortDataFrame } from '../../dataframe'; + +export interface SortByField { + field: string; + desc?: boolean; + index?: number; +} + +export interface SortByTransformerOptions { + // NOTE: this structure supports an array, however only the first entry is used + // future versions may support multi-sort options + sort: SortByField[]; +} + +export const sortByTransformer: DataTransformerInfo = { + id: DataTransformerID.sortBy, + name: 'Sort by', + description: 'Sort fields in a frame', + defaultOptions: { + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0 || !options?.sort?.length) { + return data; + } + return sortDataFrames(data, options.sort); + }) + ), +}; + +export function sortDataFrames(data: DataFrame[], sort: SortByField[]): DataFrame[] { + return data.map((frame) => { + const s = attachFieldIndex(frame, sort); + if (s.length && s[0].index != null) { + return sortDataFrame(frame, s[0].index, s[0].desc); + } + return frame; + }); +} + +function attachFieldIndex(frame: DataFrame, sort: SortByField[]): SortByField[] { + return sort.map((s) => { + if (s.index != null) { + // null or undefined + return s; + } + return { + ...s, + index: frame.fields.findIndex((f) => s.field === getFieldDisplayName(f, frame)), + }; + }); +} diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts new file mode 100644 index 0000000..6d2db5b --- /dev/null +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -0,0 +1,95 @@ +import { ComponentType } from 'react'; +import { RegistryItem, Registry } from '../utils/Registry'; +import { + NumberFieldConfigSettings, + SliderFieldConfigSettings, + SelectFieldConfigSettings, + StringFieldConfigSettings, +} from '../field'; +import { OptionEditorConfig } from './options'; + +/** + * Option editor registry item + */ +export interface OptionsEditorItem + extends RegistryItem, + OptionEditorConfig { + /** + * React component used to edit the options property + */ + editor: ComponentType; + + /* + * @param value + */ + getItemsCount?: (value?: TValue) => number; +} + +/** + * Describes an API for option editors UI builder + */ +export interface OptionsUIRegistryBuilderAPI< + TOptions, + TEditorProps, + T extends OptionsEditorItem +> { + addNumberInput?( + config: OptionEditorConfig + ): this; + + addSliderInput?( + config: OptionEditorConfig + ): this; + + addTextInput?( + config: OptionEditorConfig + ): this; + + addStringArray?( + config: OptionEditorConfig + ): this; + + addSelect?>( + config: OptionEditorConfig + ): this; + + addRadio? = SelectFieldConfigSettings>( + config: OptionEditorConfig + ): this; + + addBooleanSwitch?(config: OptionEditorConfig): this; + + addUnitPicker?(config: OptionEditorConfig): this; + + addColorPicker?(config: OptionEditorConfig): this; + + /** + * Enables custom editor definition + * @param config + */ + addCustomEditor(config: OptionsEditorItem): this; + + /** + * Returns registry of option editors + */ + getRegistry: () => Registry; +} + +export abstract class OptionsUIRegistryBuilder< + TOptions, + TEditorProps, + T extends OptionsEditorItem +> implements OptionsUIRegistryBuilderAPI { + private properties: T[] = []; + + addCustomEditor(config: T & OptionsEditorItem): this { + this.properties.push(config); + return this; + } + + getRegistry() { + return new Registry(() => { + return this.properties; + }); + } +} diff --git a/packages/grafana-data/src/types/ScopedVars.ts b/packages/grafana-data/src/types/ScopedVars.ts new file mode 100644 index 0000000..b066d3a --- /dev/null +++ b/packages/grafana-data/src/types/ScopedVars.ts @@ -0,0 +1,7 @@ +export interface ScopedVar { + text: any; + value: T; + [key: string]: any; +} + +export interface ScopedVars extends Record {} diff --git a/packages/grafana-data/src/types/alerts.ts b/packages/grafana-data/src/types/alerts.ts new file mode 100644 index 0000000..895ff32 --- /dev/null +++ b/packages/grafana-data/src/types/alerts.ts @@ -0,0 +1,22 @@ +/** + * @internal -- might be replaced by next generation Alerting + */ +export enum AlertState { + NoData = 'no_data', + Paused = 'paused', + Alerting = 'alerting', + OK = 'ok', + Pending = 'pending', + Unknown = 'unknown', +} + +/** + * @internal -- might be replaced by next generation Alerting + */ +export interface AlertStateInfo { + id: number; + dashboardId: number; + panelId: number; + state: AlertState; + newStateDate: string; +} diff --git a/packages/grafana-data/src/types/annotations.ts b/packages/grafana-data/src/types/annotations.ts new file mode 100644 index 0000000..b5397da --- /dev/null +++ b/packages/grafana-data/src/types/annotations.ts @@ -0,0 +1,99 @@ +import { Observable } from 'rxjs'; +import { ComponentType } from 'react'; + +import { DataQuery, QueryEditorProps } from './datasource'; +import { DataFrame } from './dataFrame'; + +/** + * This JSON object is stored in the dashboard json model. + */ +export interface AnnotationQuery { + datasource?: string | null; + enable: boolean; + name: string; + iconColor: string; + hide?: boolean; + builtIn?: number; + type?: string; + snapshotData?: any; + + // Standard datasource query + target?: TQuery; + + // Convert a dataframe to an AnnotationEvent + mappings?: AnnotationEventMappings; + + // Sadly plugins can set any propery directly on the main object + [key: string]: any; +} + +export interface AnnotationEvent { + id?: string; + annotation?: any; + dashboardId?: number; + panelId?: number; + userId?: number; + login?: string; + email?: string; + avatarUrl?: string; + time?: number; + timeEnd?: number; + isRegion?: boolean; + title?: string; + text?: string; + type?: string; + tags?: string[]; + color?: string; + alertId?: number; + newState?: string; + + // Currently used to merge annotations from alerts and dashboard + source?: any; // source.type === 'dashboard' +} + +/** + * @alpha -- any value other than `field` is experimental + */ +export enum AnnotationEventFieldSource { + Field = 'field', // Default -- find the value with a matching key + Text = 'text', // Write a constant string into the value + Skip = 'skip', // Do not include the field +} + +export interface AnnotationEventFieldMapping { + source?: AnnotationEventFieldSource; // defaults to 'field' + value?: string; + regex?: string; +} + +export type AnnotationEventMappings = Partial>; + +/** + * Since Grafana 7.2 + * + * This offers a generic approach to annotation processing + */ +export interface AnnotationSupport> { + /** + * This hook lets you manipulate any existing stored values before running them though the processor. + * This is particularly helpful when dealing with migrating old formats. ie query as a string vs object + */ + prepareAnnotation?(json: any): TAnno; + + /** + * Convert the stored JSON model to a standard datasource query object. + * This query will be executed in the datasource and the results converted into events. + * Returning an undefined result will quietly skip query execution + */ + prepareQuery?(anno: TAnno): TQuery | undefined; + + /** + * When the standard frame > event processing is insufficient, this allows explicit control of the mappings + */ + processEvents?(anno: TAnno, data: DataFrame[]): Observable; + + /** + * Specify a custom QueryEditor for the annotation page. If not specified, the standard one will be used + */ + QueryEditor?: ComponentType>; +} diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts new file mode 100644 index 0000000..4f0c9ff --- /dev/null +++ b/packages/grafana-data/src/types/app.ts @@ -0,0 +1,90 @@ +import { ComponentClass } from 'react'; +import { KeyValue } from './data'; +import { NavModel } from './navModel'; +import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin'; + +export enum CoreApp { + Dashboard = 'dashboard', + Explore = 'explore', + Unknown = 'unknown', +} + +export interface AppRootProps { + meta: AppPluginMeta; + + path: string; // The URL path to this page + query: KeyValue; // The URL query parameters + + /** + * Pass the nav model to the container... is there a better way? + */ + onNavChanged: (nav: NavModel) => void; +} + +export interface AppPluginMeta extends PluginMeta { + // TODO anything specific to apps? +} + +export class AppPlugin extends GrafanaPlugin> { + // Content under: /a/${plugin-id}/* + root?: ComponentClass>; + rootNav?: NavModel; // Initial navigation model + + // Old style pages + angularPages?: { [component: string]: any }; + + /** + * Called after the module has loaded, and before the app is used. + * This function may be called multiple times on the same instance. + * The first time, `this.meta` will be undefined + */ + init(meta: AppPluginMeta) {} + + /** + * Set the component displayed under: + * /a/${plugin-id}/* + * + * If the NavModel is configured, the page will have a managed frame, otheriwse it has full control. + * + * NOTE: this structure will change in 7.2+ so that it is managed with a normal react router + */ + setRootPage(root: ComponentClass>, rootNav?: NavModel) { + this.root = root; + this.rootNav = rootNav; + return this; + } + + setComponentsFromLegacyExports(pluginExports: any) { + if (pluginExports.ConfigCtrl) { + this.angularConfigCtrl = pluginExports.ConfigCtrl; + } + + if (this.meta && this.meta.includes) { + for (const include of this.meta.includes) { + if (include.type === PluginIncludeType.page && include.component) { + const exp = pluginExports[include.component]; + + if (!exp) { + console.warn('App Page uses unknown component: ', include.component, this.meta); + continue; + } + + if (!this.angularPages) { + this.angularPages = {}; + } + + this.angularPages[include.component] = exp; + } + } + } + } +} + +/** + * Defines life cycle of a feature + * @internal + */ +export enum FeatureState { + alpha = 'alpha', + beta = 'beta', +} diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts new file mode 100644 index 0000000..14ed639 --- /dev/null +++ b/packages/grafana-data/src/types/config.ts @@ -0,0 +1,134 @@ +import { DataSourceInstanceSettings } from './datasource'; +import { PanelPluginMeta } from './panel'; +import { GrafanaTheme } from './theme'; +import { SystemDateFormatSettings } from '../datetime'; +import { GrafanaTheme2 } from '../themes'; + +/** + * Describes the build information that will be available via the Grafana configuration. + * + * @public + */ +export interface BuildInfo { + version: string; + commit: string; + /** + * Is set to true when running Grafana Enterprise edition. + * + * @deprecated use `licenseInfo.hasLicense` instead + */ + isEnterprise: boolean; + env: string; + edition: GrafanaEdition; + latestVersion: string; + hasUpdate: boolean; + hideVersion: boolean; +} + +/** + * @internal + */ +export enum GrafanaEdition { + OpenSource = 'Open Source', + Pro = 'Pro', + Enterprise = 'Enterprise', +} + +/** + * Describes available feature toggles in Grafana. These can be configured via the + * `conf/custom.ini` to enable features under development or not yet available in + * stable version. + * + * @public + */ +export interface FeatureToggles { + [name: string]: boolean; + + ngalert: boolean; + trimDefaults: boolean; + accesscontrol: boolean; + + /** + * @remarks + * Available only in Grafana Enterprise + */ + meta: boolean; + reportVariables: boolean; +} + +/** + * Describes the license information about the current running instance of Grafana. + * + * @public + */ +export interface LicenseInfo { + hasLicense: boolean; + expiry: number; + licenseUrl: string; + stateInfo: string; + hasValidLicense: boolean; + edition: GrafanaEdition; +} + +/** + * Describes Sentry integration config + * + * @public + */ +export interface SentryConfig { + enabled: boolean; + dsn: string; + customEndpoint: string; + sampleRate: number; +} + +/** + * Describes all the different Grafana configuration values available for an instance. + * + * @public + */ +export interface GrafanaConfig { + datasources: { [str: string]: DataSourceInstanceSettings }; + panels: { [key: string]: PanelPluginMeta }; + minRefreshInterval: string; + appSubUrl: string; + windowTitlePrefix: string; + buildInfo: BuildInfo; + newPanelTitle: string; + bootData: any; + externalUserMngLinkUrl: string; + externalUserMngLinkName: string; + externalUserMngInfo: string; + allowOrgCreate: boolean; + disableLoginForm: boolean; + defaultDatasource: string; + alertingEnabled: boolean; + alertingErrorOrTimeout: string; + alertingNoDataOrNullValues: string; + alertingMinInterval: number; + authProxyEnabled: boolean; + exploreEnabled: boolean; + ldapEnabled: boolean; + sigV4AuthEnabled: boolean; + samlEnabled: boolean; + autoAssignOrg: boolean; + verifyEmailEnabled: boolean; + oauth: any; + disableUserSignUp: boolean; + loginHint: any; + passwordHint: any; + loginError: any; + navTree: any; + viewersCanEdit: boolean; + editorsCanAdmin: boolean; + disableSanitizeHtml: boolean; + theme: GrafanaTheme; + theme2: GrafanaTheme2; + pluginsToPreload: string[]; + featureToggles: FeatureToggles; + licenseInfo: LicenseInfo; + http2Enabled: boolean; + dateFormats?: SystemDateFormatSettings; + sentry: SentryConfig; + customTheme?: any; +} diff --git a/packages/grafana-data/src/types/dashboard.cue b/packages/grafana-data/src/types/dashboard.cue new file mode 100644 index 0000000..a5b07f9 --- /dev/null +++ b/packages/grafana-data/src/types/dashboard.cue @@ -0,0 +1,213 @@ +package grafanaschema + +import "github.com/grafana/grafana/cue/scuemata" + +Family: scuemata.#Family & { + lineages: [ + [ + { // 0.0 + // Unique numeric identifier for the dashboard. + // TODO must isolate or remove identifiers local to a Grafana instance...? + id?: number + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid: string + // Title of dashboard. + title?: string + // Description of dashboard. + description?: string + + gnetId?: string + // Tags associated with dashboard. + tags?: [...string] + // Theme of dashboard. + style: *"light" | "dark" + // Timezone of dashboard, + timezone?: *"browser" | "utc" + // Whether a dashboard is editable or not. + editable: bool | *true + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + graphTooltip: >=0 & <=2 | *0 + // Time range for dashboard, e.g. last 6 hours, last 7 days, etc + time?: { + from: string | *"now-6h" + to: string | *"now" + } + // Timepicker metadata. + timepicker?: { + // Whether timepicker is collapsed or not. + collapse: bool | *false + // Whether timepicker is enabled or not. + enable: bool | *true + // Whether timepicker is visible or not. + hidden: bool | *false + // Selectable intervals for auto-refresh. + refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + } + // Templating. + templating?: list: [...{...}] + // Annotations. + annotations?: list: [...{ + builtIn: number | *0 + // Datasource to use for annotation. + datasource: string + // Whether annotation is enabled. + enable?: bool | *true + // Whether to hide annotation. + hide?: bool | *false + // Annotation icon color. + iconColor?: string + // Name of annotation. + name?: string + type: string | *"dashboard" + // Query for annotation data. + rawQuery?: string + showIn: number | *0 + }] + // Auto-refresh interval. + refresh?: string + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: number | *25 + // Version of the dashboard, incremented each time the dashboard is updated. + version?: number + panels?: [...#Panel] + + // Dashboard panels. Panels are canonically defined inline + // because they share a version timeline with the dashboard + // schema; they do not vary independently. We create a separate, + // synthetic Family to represent them in Go, for ease of generating + // e.g. JSON Schema. + #Panel: { + // The panel plugin type id. + type: !="" + + // Internal - the exact major and minor versions of the panel plugin + // schema. Hidden and therefore not a part of the data model, but + // expected to be filled with panel plugin schema versions so that it's + // possible to figure out which schema version matched on a successful + // unification. + // _pv: { maj: int, min: int } + // The major and minor versions of the panel plugin for this schema. + // TODO 2-tuple list instead of struct? + panelSchema: { maj: number, min: number } + + // Panel title. + title?: string + // Description. + description?: string + // Whether to display the panel without a background. + transparent: bool | *false + // Name of default datasource. + datasource?: string + // Grid position. + gridPos?: { + // Panel + h: number & >0 | *9 + // Panel + w: number & >0 & <=24 | *12 + // Panel x + x: number & >=0 & <24 | *0 + // Panel y + y: number & >=0 | *0 + // true if fixed + static?: bool + } + // Panel links. + // links?: [..._panelLink] + // Name of template variable to repeat for. + repeat?: string + // Direction to repeat in if 'repeat' is set. + // "h" for horizontal, "v" for vertical. + repeatDirection: *"h" | "v" + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + targets?: [...{}] + + // The values depend on panel type + options: {...} + + fieldConfig: { + defaults: { + // The display value for this field. This supports template variables blank is auto + displayName?: string + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string + + // Human readable field metadata + description?: string + + // An explict path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool + + // True if data source field supports ad-hoc filters + filterable?: bool + + // Numeric Options + unit?: string + + // Significant digits (for display) + decimals?: number + + min?: number + max?: number + + // // Convert input values into a display string + // mappings?: ValueMapping[]; + + // // Map numeric values to states + // thresholds?: ThresholdsConfig; + + // // Map values to a display color + // color?: FieldColor; + + // // Used when reducing field values + // nullValueMode?: NullValueMode; + + // // The behavior when clicking on a result + // links?: DataLink[]; + + // Alternative to empty string + noValue?: string + + // Can always exist. Valid fields within this are + // defined by the panel plugin - that's the + // PanelFieldConfig that comes from the plugin. + custom?: {...} + } + overrides: [...{ + matcher: { + id: string | *"" + options?: _ + } + properties: [...{ + id: string | *"" + value?: _ + }] + }] + } + } + } + ] + ] +} + +#Latest: { + #Dashboard: dashboardFamily.latest + #Panel: dashboardFamily.latest._Panel +} diff --git a/packages/grafana-data/src/types/dashboard.ts b/packages/grafana-data/src/types/dashboard.ts new file mode 100644 index 0000000..85dbe95 --- /dev/null +++ b/packages/grafana-data/src/types/dashboard.ts @@ -0,0 +1,5 @@ +export enum DashboardCursorSync { + Off, + Crosshair, + Tooltip, +} diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts new file mode 100644 index 0000000..ae88e3b --- /dev/null +++ b/packages/grafana-data/src/types/data.ts @@ -0,0 +1,175 @@ +import { FieldConfig } from './dataFrame'; +import { DataTransformerConfig } from './transformations'; +import { ApplyFieldOverrideOptions } from './fieldOverrides'; +import { PanelPluginDataSupport } from '.'; + +export type KeyValue = Record; + +/** + * Represent panel data loading state. + * @public + */ +export enum LoadingState { + NotStarted = 'NotStarted', + Loading = 'Loading', + Streaming = 'Streaming', + Done = 'Done', + Error = 'Error', +} + +export enum DataTopic { + Annotations = 'annotations', +} + +// Should be kept in sync with grafana-plugin-sdk-go/data/frame_meta.go +export type PreferredVisualisationType = 'graph' | 'table' | 'logs' | 'trace' | 'nodeGraph'; + +/** + * @public + */ +export interface QueryResultMeta { + /** DatasSource Specific Values */ + custom?: Record; + + /** Stats */ + stats?: QueryResultMetaStat[]; + + /** Meta Notices */ + notices?: QueryResultMetaNotice[]; + + /** Used to track transformation ids that where part of the processing */ + transformations?: string[]; + + /** Currently used to show results in Explore only in preferred visualisation option */ + preferredVisualisationType?: PreferredVisualisationType; + + /** The path for live stream updates for this frame */ + channel?: string; + + /** + * Optionally identify which topic the frame should be assigned to. + * A value specified in the response will override what the request asked for. + */ + dataTopic?: DataTopic; + + /** + * This is the raw query sent to the underlying system. All macros and templating + * as been applied. When metadata contains this value, it will be shown in the query inspector + */ + executedQueryString?: string; + + /** + * A browsable path on the datasource + */ + path?: string; + + /** + * defaults to '/' + */ + pathSeparator?: string; + + /** + * Legacy data source specific, should be moved to custom + * */ + alignmentPeriod?: number; // used by cloud monitoring + searchWords?: string[]; // used by log models and loki + limit?: number; // used by log models and loki + json?: boolean; // used to keep track of old json doc values + instant?: boolean; +} + +export interface QueryResultMetaStat extends FieldConfig { + displayName: string; + value: number; +} + +/** + * QueryResultMetaNotice is a structure that provides user notices for query result data + * @public + */ +export interface QueryResultMetaNotice { + /** + * Specify the notice severity + */ + severity: 'info' | 'warning' | 'error'; + + /** + * Notice descriptive text + */ + text: string; + + /** + * An optional link that may be displayed in the UI. + * This value may be an absolute URL or relative to grafana root + */ + link?: string; + + /** + * Optionally suggest an appropriate tab for the panel inspector + */ + inspect?: 'meta' | 'error' | 'data' | 'stats'; +} + +/** + * @public + */ +export interface QueryResultBase { + /** + * Matches the query target refId + */ + refId?: string; + + /** + * Used by some backend data sources to communicate back info about the execution (generated sql, timing) + */ + meta?: QueryResultMeta; +} + +export interface Labels { + [key: string]: string; +} + +export interface Column { + text: string; // For a Column, the 'text' is the field name + filterable?: boolean; + unit?: string; + custom?: Record; +} + +export interface TableData extends QueryResultBase { + name?: string; + columns: Column[]; + rows: any[][]; + type?: string; +} + +export type TimeSeriesValue = number | null; + +export type TimeSeriesPoints = TimeSeriesValue[][]; + +export interface TimeSeries extends QueryResultBase { + target: string; + /** + * If name is manually configured via an alias / legend pattern + */ + title?: string; + datapoints: TimeSeriesPoints; + unit?: string; + tags?: Labels; +} + +export enum NullValueMode { + Null = 'null', + Ignore = 'connected', + AsZero = 'null as zero', +} + +/** + * Describes and API for exposing panel specific data configurations. + */ +export interface DataConfigSource { + configRev?: number; + getDataSupport: () => PanelPluginDataSupport; + getTransformations: () => DataTransformerConfig[] | undefined; + getFieldOverrideOptions: () => ApplyFieldOverrideOptions | undefined; +} diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts new file mode 100644 index 0000000..3b055ba --- /dev/null +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -0,0 +1,229 @@ +import { ThresholdsConfig } from './thresholds'; +import { ValueMapping } from './valueMapping'; +import { QueryResultBase, Labels, NullValueMode } from './data'; +import { DisplayProcessor, DisplayValue } from './displayValue'; +import { DataLink, LinkModel } from './dataLink'; +import { Vector } from './vector'; +import { FieldColor } from './fieldColor'; +import { ScopedVars } from './ScopedVars'; + +/** @public */ +export enum FieldType { + time = 'time', // or date + number = 'number', + string = 'string', + boolean = 'boolean', + // Used to detect that the value is some kind of trace data to help with the visualisation and processing. + trace = 'trace', + other = 'other', // Object, Array, etc +} + +/** + * @public + * Every property is optional + * + * Plugins may extend this with additional properties. Something like series overrides + */ +export interface FieldConfig { + /** + * The display value for this field. This supports template variables blank is auto + */ + displayName?: string; + + /** + * This can be used by data sources that return and explicit naming structure for values and labels + * When this property is configured, this value is used rather than the default naming strategy. + */ + displayNameFromDS?: string; + + /** + * Human readable field metadata + */ + description?: string; + + /** + * An explict path to the field in the datasource. When the frame meta includes a path, + * This will default to `${frame.meta.path}/${field.name} + * + * When defined, this value can be used as an identifier within the datasource scope, and + * may be used to update the results + */ + path?: string; + + /** + * True if data source can write a value to the path. Auth/authz are supported separately + */ + writeable?: boolean; + + /** + * True if data source field supports ad-hoc filters + */ + filterable?: boolean; + + // Numeric Options + unit?: string; + decimals?: number | null; // Significant digits (for display) + min?: number | null; + max?: number | null; + + // Convert input values into a display string + mappings?: ValueMapping[]; + + // Map numeric values to states + thresholds?: ThresholdsConfig; + + // Map values to a display color + color?: FieldColor; + + // Used when reducing field values + nullValueMode?: NullValueMode; + + // The behavior when clicking on a result + links?: DataLink[]; + + // Alternative to empty string + noValue?: string; + + // Panel Specific Values + custom?: TOptions; +} + +/** @public */ +export interface ValueLinkConfig { + /** + * Result of field reduction + */ + calculatedValue?: DisplayValue; + /** + * Index of the value row within Field. Should be provided only when value is not a result of a reduction + */ + valueRowIndex?: number; +} + +export interface Field> { + /** + * Name of the field (column) + */ + name: string; + /** + * Field value type (string, number, etc) + */ + type: FieldType; + /** + * Meta info about how field and how to display it + */ + config: FieldConfig; + values: V; // The raw field values + labels?: Labels; + + /** + * Cached values with appropriate display and id values + */ + state?: FieldState | null; + + /** + * Convert text to the field value + */ + parse?: (value: any) => T; + + /** + * Convert a value for display + */ + display?: DisplayProcessor; + + /** + * Get value data links with variables interpolated + */ + getLinks?: (config: ValueLinkConfig) => Array>; +} + +/** @alpha */ +export interface FieldState { + /** + * An appropriate name for the field (does not include frame info) + */ + displayName?: string | null; + + /** + * Cache of reduced values + */ + calcs?: FieldCalcs; + + /** + * The numeric range for values in this field. This value will respect the min/max + * set in field config, or when set to `auto` this will have the min/max for all data + * in the response + */ + range?: NumericRange; + + /** + * Appropriate values for templating + */ + scopedVars?: ScopedVars; + + /** + * Series index is index for this field in a larger data set that can span multiple DataFrames + * Useful for assigning color to series by looking up a color in a palette using this index + */ + seriesIndex?: number; + + /** + * Location of this field within the context frames results + * + * @internal -- we will try to make this unnecessary + */ + origin?: DataFrameFieldIndex; +} + +/** @public */ +export interface NumericRange { + min?: number | null; + max?: number | null; + delta: number; +} + +export interface DataFrame extends QueryResultBase { + name?: string; + fields: Field[]; // All fields of equal length + + // The number of rows + length: number; +} + +/** + * @public + * Like a field, but properties are optional and values may be a simple array + */ +export interface FieldDTO { + name: string; // The column name + type?: FieldType; + config?: FieldConfig; + values?: Vector | T[]; // toJSON will always be T[], input could be either + labels?: Labels; +} + +/** + * @public + * Like a DataFrame, but fields may be a FieldDTO + */ +export interface DataFrameDTO extends QueryResultBase { + name?: string; + fields: Array; +} + +export interface FieldCalcs extends Record {} + +export const TIME_SERIES_VALUE_FIELD_NAME = 'Value'; +export const TIME_SERIES_TIME_FIELD_NAME = 'Time'; +export const TIME_SERIES_METRIC_FIELD_NAME = 'Metric'; + +/** + * Describes where a specific data frame field is located within a + * dataset of type DataFrame[] + * + * @internal -- we will try to make this unnecessary + */ +export interface DataFrameFieldIndex { + frameIndex: number; + fieldIndex: number; +} diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts new file mode 100644 index 0000000..b83f182 --- /dev/null +++ b/packages/grafana-data/src/types/dataLink.ts @@ -0,0 +1,91 @@ +import { DataQuery } from './datasource'; +import { InterpolateFunction } from './panel'; + +/** + * Callback info for DataLink click events + */ +export interface DataLinkClickEvent { + origin: T; + replaceVariables: InterpolateFunction | undefined; + e?: any; // mouse|react event +} + +/** + * Link configuration. The values may contain variables that need to be + * processed before showing the link to user. + * + * TODO: is not strictly true for internal links as we do not need refId for example but all + * data source defined queries extend this so this is more for documentation. + */ +export interface DataLink { + title: string; + targetBlank?: boolean; + + // 3: The URL if others did not set it first + url: string; + + // 2: If exists, use this to construct the URL + // Not saved in JSON/DTO + onBuildUrl?: (event: DataLinkClickEvent) => string; + + // 1: If exists, handle click directly + // Not saved in JSON/DTO + onClick?: (event: DataLinkClickEvent) => void; + + // If dataLink represents internal link this has to be filled. Internal link is defined as a query in a particular + // data source that we want to show to the user. Usually this results in a link to explore but can also lead to + // more custom onClick behaviour if needed. + // @internal and subject to change in future releases + internal?: InternalDataLink; +} + +/** @internal */ +export interface InternalDataLink { + query: T; + datasourceUid: string; + datasourceName: string; +} + +export type LinkTarget = '_blank' | '_self' | undefined; + +/** + * Processed Link Model. The values are ready to use + */ +export interface LinkModel { + href: string; + title: string; + target: LinkTarget; + origin: T; + + // When a click callback exists, this is passed the raw mouse|react event + onClick?: (e: any) => void; +} + +/** + * Provides a way to produce links on demand + * + * TODO: ScopedVars in in GrafanaUI package! + */ +export interface LinkModelSupplier { + getLinks(replaceVariables?: InterpolateFunction): Array>; +} + +export enum VariableOrigin { + Series = 'series', + Field = 'field', + Fields = 'fields', + Value = 'value', + BuiltIn = 'built-in', + Template = 'template', +} + +export interface VariableSuggestion { + value: string; + label: string; + documentation?: string; + origin: VariableOrigin; +} + +export enum VariableSuggestionsScope { + Values = 'values', +} diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts new file mode 100644 index 0000000..583d4bf --- /dev/null +++ b/packages/grafana-data/src/types/datasource.ts @@ -0,0 +1,646 @@ +import { Observable } from 'rxjs'; +import { ComponentType } from 'react'; +import { GrafanaPlugin, PluginMeta } from './plugin'; +import { PanelData } from './panel'; +import { LogRowModel } from './logs'; +import { AnnotationEvent, AnnotationQuery, AnnotationSupport } from './annotations'; +import { DataTopic, KeyValue, LoadingState, TableData, TimeSeries } from './data'; +import { DataFrame, DataFrameDTO } from './dataFrame'; +import { RawTimeRange, TimeRange } from './time'; +import { ScopedVars } from './ScopedVars'; +import { CoreApp } from './app'; +import { LiveChannelSupport } from './live'; +import { CustomVariableSupport, DataSourceVariableSupport, StandardVariableSupport } from './variables'; +import { makeClassES5Compatible } from '../utils/makeClassES5Compatible'; + +export interface DataSourcePluginOptionsEditorProps { + options: DataSourceSettings; + onOptionsChange: (options: DataSourceSettings) => void; +} + +// Utility type to extract the query type TQuery from a class extending DataSourceApi +export type DataSourceQueryType = DSType extends DataSourceApi ? TQuery : never; + +// Utility type to extract the options type TOptions from a class extending DataSourceApi +export type DataSourceOptionsType = DSType extends DataSourceApi ? TOptions : never; + +export class DataSourcePlugin< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType, + TSecureOptions = {} +> extends GrafanaPlugin> { + components: DataSourcePluginComponents = {}; + + constructor(public DataSourceClass: DataSourceConstructor) { + super(); + } + + setConfigEditor(editor: ComponentType>) { + this.components.ConfigEditor = editor; + return this; + } + + setConfigCtrl(ConfigCtrl: any) { + this.angularConfigCtrl = ConfigCtrl; + return this; + } + + setQueryCtrl(QueryCtrl: any) { + this.components.QueryCtrl = QueryCtrl; + return this; + } + + setAnnotationQueryCtrl(AnnotationsQueryCtrl: any) { + this.components.AnnotationsQueryCtrl = AnnotationsQueryCtrl; + return this; + } + + setQueryEditor(QueryEditor: ComponentType>) { + this.components.QueryEditor = QueryEditor; + return this; + } + + setExploreQueryField(ExploreQueryField: ComponentType>) { + this.components.ExploreQueryField = ExploreQueryField; + return this; + } + + setExploreMetricsQueryField(ExploreQueryField: ComponentType>) { + this.components.ExploreMetricsQueryField = ExploreQueryField; + return this; + } + + setExploreLogsQueryField(ExploreQueryField: ComponentType>) { + this.components.ExploreLogsQueryField = ExploreQueryField; + return this; + } + + setQueryEditorHelp(QueryEditorHelp: ComponentType) { + this.components.QueryEditorHelp = QueryEditorHelp; + return this; + } + + /** + * @deprecated prefer using `setQueryEditorHelp` + */ + setExploreStartPage(ExploreStartPage: ComponentType) { + return this.setQueryEditorHelp(ExploreStartPage); + } + + /* + * @deprecated -- prefer using {@link StandardVariableSupport} or {@link CustomVariableSupport} or {@link DataSourceVariableSupport} in data source instead + * */ + setVariableQueryEditor(VariableQueryEditor: any) { + this.components.VariableQueryEditor = VariableQueryEditor; + return this; + } + + setMetadataInspector(MetadataInspector: ComponentType>) { + this.components.MetadataInspector = MetadataInspector; + return this; + } + + setComponentsFromLegacyExports(pluginExports: any) { + this.angularConfigCtrl = pluginExports.ConfigCtrl; + + this.components.QueryCtrl = pluginExports.QueryCtrl; + this.components.AnnotationsQueryCtrl = pluginExports.AnnotationsQueryCtrl; + this.components.ExploreQueryField = pluginExports.ExploreQueryField; + this.components.QueryEditor = pluginExports.QueryEditor; + this.components.QueryEditorHelp = pluginExports.QueryEditorHelp; + this.components.VariableQueryEditor = pluginExports.VariableQueryEditor; + } +} + +export interface DataSourcePluginMeta extends PluginMeta { + builtIn?: boolean; // Is this for all + metrics?: boolean; + logs?: boolean; + annotations?: boolean; + alerting?: boolean; + tracing?: boolean; + mixed?: boolean; + hasQueryHelp?: boolean; + category?: string; + queryOptions?: PluginMetaQueryOptions; + sort?: number; + streaming?: boolean; + unlicensed?: boolean; +} + +interface PluginMetaQueryOptions { + cacheTimeout?: boolean; + maxDataPoints?: boolean; + minInterval?: boolean; +} + +export interface DataSourcePluginComponents< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData, + TSecureOptions = {} +> { + QueryCtrl?: any; + AnnotationsQueryCtrl?: any; + VariableQueryEditor?: any; + QueryEditor?: ComponentType>; + ExploreQueryField?: ComponentType>; + ExploreMetricsQueryField?: ComponentType>; + ExploreLogsQueryField?: ComponentType>; + QueryEditorHelp?: ComponentType; + ConfigEditor?: ComponentType>; + MetadataInspector?: ComponentType>; +} + +// Only exported for tests +export interface DataSourceConstructor< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> { + new (instanceSettings: DataSourceInstanceSettings, ...args: any[]): DSType; +} + +/** + * The main data source abstraction interface, represents an instance of a data source + * + * Although this is a class, datasource implementations do not *yet* need to extend it. + * As such, we can not yet add functions with default implementations. + */ +abstract class DataSourceApi< + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData, + TQueryImportConfiguration extends Record = {} +> { + /** + * Set in constructor + */ + readonly name: string; + + /** + * Set in constructor + */ + readonly id: number; + + /** + * Set in constructor + */ + readonly type: string; + + /** + * Set in constructor + */ + readonly uid: string; + + /** + * min interval range + */ + interval?: string; + + constructor(instanceSettings: DataSourceInstanceSettings) { + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.type = instanceSettings.type; + this.meta = {} as DataSourcePluginMeta; + this.uid = instanceSettings.uid; + } + + /** + * Imports queries from a different datasource + */ + async importQueries?(queries: DataQuery[], originDataSource: DataSourceApi): Promise; + + /** + * Returns configuration for importing queries from other data sources + */ + getImportQueryConfiguration?(): TQueryImportConfiguration; + + /** + * Initializes a datasource after instantiation + */ + init?: () => void; + + /** + * Query for data, and optionally stream results + */ + abstract query(request: DataQueryRequest): Promise | Observable; + + /** + * Test & verify datasource settings & connection details + */ + abstract testDatasource(): Promise; + + /** + * Get hints for query improvements + */ + getQueryHints?(query: TQuery, results: any[], ...rest: any): QueryHint[]; + + /** + * Convert a query to a simple text string + */ + getQueryDisplayText?(query: TQuery): string; + + /** + * Retrieve context for a given log row + */ + getLogRowContext?: ( + row: LogRowModel, + options?: TContextQueryOptions + ) => Promise; + + /** + * Variable query action. + */ + metricFindQuery?(query: any, options?: any): Promise; + + /** + * Get tag keys for adhoc filters + */ + getTagKeys?(options?: any): Promise; + + /** + * Get tag values for adhoc filters + */ + getTagValues?(options: any): Promise; + + /** + * Set after constructor call, as the data source instance is the most common thing to pass around + * we attach the components to this instance for easy access + */ + components?: DataSourcePluginComponents, TQuery, TOptions>; + + /** + * static information about the datasource + */ + meta: DataSourcePluginMeta; + + /** + * Used by alerting to check if query contains template variables + */ + targetContainsTemplate?(query: TQuery): boolean; + + /** + * Used in explore + */ + modifyQuery?(query: TQuery, action: QueryFixAction): TQuery; + + /** + * Used in explore + */ + getHighlighterExpression?(query: TQuery): string[]; + + /** + * Used in explore + */ + languageProvider?: any; + + getVersion?(optionalOptions?: any): Promise; + + showContextToggle?(row?: LogRowModel): boolean; + + interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[]; + + /** + * An annotation processor allows explicit control for how annotations are managed. + * + * It is only necessary to configure an annotation processor if the default behavior is not desirable + */ + annotations?: AnnotationSupport; + + /** + * Can be optionally implemented to allow datasource to be a source of annotations for dashboard. + * This function will only be called if an angular {@link AnnotationsQueryCtrl} is configured and + * the {@link annotations} is undefined + * + * @deprecated -- prefer using {@link AnnotationSupport} + */ + annotationQuery?(options: AnnotationQueryRequest): Promise; + + /** + * Define live streaming behavior within this datasource settings + * + * Note: `plugin.json` must also define `live: true` + * + * @alpha -- experimental + */ + channelSupport?: LiveChannelSupport; + + /** + * Defines new variable support + * @alpha -- experimental + */ + variables?: + | StandardVariableSupport> + | CustomVariableSupport> + | DataSourceVariableSupport>; +} + +export interface MetadataInspectorProps< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> { + datasource: DSType; + + // All Data from this DataSource + data: DataFrame[]; +} + +export interface QueryEditorProps< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData, + TVQuery extends DataQuery = TQuery +> { + datasource: DSType; + query: TVQuery; + onRunQuery: () => void; + onChange: (value: TVQuery) => void; + onBlur?: () => void; + /** + * Contains query response filtered by refId of QueryResultBase and possible query error + */ + data?: PanelData; + range?: TimeRange; + exploreId?: any; + history?: HistoryItem[]; + queries?: DataQuery[]; +} + +// TODO: not really needed but used as type in some data sources and in DataQueryRequest +export enum ExploreMode { + Logs = 'Logs', + Metrics = 'Metrics', + Tracing = 'Tracing', +} + +export interface ExploreQueryFieldProps< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> extends QueryEditorProps { + history: any[]; + onBlur?: () => void; + exploreId?: any; +} + +export interface QueryEditorHelpProps { + datasource: DataSourceApi; + onClickExample: (query: DataQuery) => void; + exploreId?: any; +} + +/** + * Starting in v6.2 DataFrame can represent both TimeSeries and TableData + */ +export type LegacyResponseData = TimeSeries | TableData | any; + +export type DataQueryResponseData = DataFrame | DataFrameDTO | LegacyResponseData; + +export interface DataQueryResponse { + /** + * The response data. When streaming, this may be empty + * or a partial result set + */ + data: DataQueryResponseData[]; + + /** + * When returning multiple partial responses or streams + * Use this key to inform Grafana how to combine the partial responses + * Multiple responses with same key are replaced (latest used) + */ + key?: string; + + /** + * Optionally include error info along with the response data + */ + error?: DataQueryError; + + /** + * Use this to control which state the response should have + * Defaults to LoadingState.Done if state is not defined + */ + state?: LoadingState; +} + +/** + * These are the common properties available to all queries in all datasources + * Specific implementations will extend this interface adding the required properties + * for the given context + */ +export interface DataQuery { + /** + * A - Z + */ + refId: string; + + /** + * true if query is disabled (ie should not be returned to the dashboard) + */ + hide?: boolean; + + /** + * Unique, guid like, string used in explore mode + */ + key?: string; + + /** + * Specify the query flavor + */ + queryType?: string; + + /** + * The data topic results should be attached to + */ + dataTopic?: DataTopic; + + /** + * For mixed data sources the selected datasource is on the query level. + * For non mixed scenarios this is undefined. + */ + datasource?: string | null; +} + +export enum DataQueryErrorType { + Cancelled = 'cancelled', + Timeout = 'timeout', + Unknown = 'unknown', +} + +export interface DataQueryError { + data?: { + message?: string; + error?: string; + }; + message?: string; + status?: string; + statusText?: string; + refId?: string; + type?: DataQueryErrorType; +} + +export interface DataQueryRequest { + requestId: string; // Used to identify results and optionally cancel the request in backendSrv + + interval: string; + intervalMs: number; + maxDataPoints?: number; + range: TimeRange; + reverse?: boolean; + scopedVars: ScopedVars; + targets: TQuery[]; + timezone: string; + app: CoreApp | string; + + cacheTimeout?: string; + rangeRaw?: RawTimeRange; + timeInfo?: string; // The query time description (blue text in the upper right) + panelId?: number; + dashboardId?: number; + + // Request Timing + startTime: number; + endTime?: number; + + // Explore state used by various datasources + liveStreaming?: boolean; +} + +export interface DataQueryTimings { + dataProcessingTime: number; +} + +export interface QueryFix { + label: string; + action?: QueryFixAction; +} + +export interface QueryFixAction { + type: string; + query?: string; + preventSubmit?: boolean; +} + +export interface QueryHint { + type: string; + label: string; + fix?: QueryFix; +} + +export interface MetricFindValue { + text: string; + value?: string | number; + expandable?: boolean; +} + +export interface DataSourceJsonData { + authType?: string; + defaultRegion?: string; + profile?: string; +} + +/** + * Data Source instance edit model. This is returned from: + * /api/datasources + */ +export interface DataSourceSettings { + id: number; + uid: string; + orgId: number; + name: string; + typeLogoUrl: string; + type: string; + typeName: string; + access: string; + url: string; + password: string; + user: string; + database: string; + basicAuth: boolean; + basicAuthPassword: string; + basicAuthUser: string; + isDefault: boolean; + jsonData: T; + secureJsonData?: S; + secureJsonFields: KeyValue; + readOnly: boolean; + withCredentials: boolean; + version?: number; +} + +/** + * Frontend settings model that is passed to Datasource constructor. This differs a bit from the model above + * as this data model is available to every user who has access to a data source (Viewers+). This is loaded + * in bootData (on page load), or from: /api/frontend/settings + */ +export interface DataSourceInstanceSettings { + id: number; + uid: string; + type: string; + name: string; + meta: DataSourcePluginMeta; + url?: string; + jsonData: T; + username?: string; + password?: string; // when access is direct, for some legacy datasources + database?: string; + isDefault?: boolean; + + /** + * This is the full Authorization header if basic auth is enabled. + * Only available here when access is Browser (direct), when access is Server (proxy) + * The basic auth header, username & password is never exposed to browser/Frontend + * so this will be empty then. + */ + basicAuth?: string; + withCredentials?: boolean; +} + +/** + * @deprecated -- use {@link DataSourceInstanceSettings} instead + */ +export interface DataSourceSelectItem { + name: string; + value: string | null; + meta: DataSourcePluginMeta; +} + +/** + * Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md + * + * @deprecated -- use {@link AnnotationSupport} + */ +export interface AnnotationQueryRequest { + range: TimeRange; + rangeRaw: RawTimeRange; + // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. + dashboard: any; + annotation: AnnotationQuery; +} + +export interface HistoryItem { + ts: number; + query: TQuery; +} + +abstract class LanguageProvider { + abstract datasource: DataSourceApi; + abstract request: (url: string, params?: any) => Promise; + + /** + * Returns startTask that resolves with a task list when main syntax is loaded. + * Task list consists of secondary promises that load more detailed language features. + */ + abstract start: () => Promise>>; + startTask?: Promise; +} + +//@ts-ignore +LanguageProvider = makeClassES5Compatible(LanguageProvider); +export { LanguageProvider }; + +//@ts-ignore +DataSourceApi = makeClassES5Compatible(DataSourceApi); + +export { DataSourceApi }; diff --git a/packages/grafana-data/src/types/displayValue.ts b/packages/grafana-data/src/types/displayValue.ts new file mode 100644 index 0000000..4d0a42d --- /dev/null +++ b/packages/grafana-data/src/types/displayValue.ts @@ -0,0 +1,45 @@ +import { FormattedValue } from '../valueFormats'; + +export type DisplayProcessor = (value: any) => DisplayValue; + +export interface DisplayValue extends FormattedValue { + /** + * Use isNaN to check if it is a real number + */ + numeric: number; + /** + * 0-1 between min & max + */ + percent?: number; + /** + * Color based on configs or Threshold + */ + color?: string; + title?: string; +} + +/** + * Explicit control for text settings + */ +export interface TextDisplayOptions { + /* Explicit text size */ + titleSize?: number; + + /* Explicit text size */ + valueSize?: number; +} + +/** + * These represents the display value with the longest title and text. + * Used to align widths and heights when displaying multiple DisplayValues + */ +export interface DisplayValueAlignmentFactors extends FormattedValue { + title: string; +} + +export type DecimalCount = number | null | undefined; + +export interface DecimalInfo { + decimals: DecimalCount; + scaledDecimals: DecimalCount; +} diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts new file mode 100644 index 0000000..18abab2 --- /dev/null +++ b/packages/grafana-data/src/types/explore.ts @@ -0,0 +1,10 @@ +import { RawTimeRange } from './time'; + +/** @internal */ +export interface ExploreUrlState { + datasource: string; + queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense + range: RawTimeRange; + originPanelId?: number; + context?: string; +} diff --git a/packages/grafana-data/src/types/fieldColor.ts b/packages/grafana-data/src/types/fieldColor.ts new file mode 100644 index 0000000..341cca5 --- /dev/null +++ b/packages/grafana-data/src/types/fieldColor.ts @@ -0,0 +1,28 @@ +/** + * @public + */ +export enum FieldColorModeId { + Thresholds = 'thresholds', + PaletteClassic = 'palette-classic', + PaletteSaturated = 'palette-saturated', + Fixed = 'fixed', +} + +/** + * @public + */ +export interface FieldColor { + /** The main color scheme mode */ + mode: FieldColorModeId | string; + /** Stores the fixed color value if mode is fixed */ + fixedColor?: string; + /** Some visualizations need to know how to assign a series color from by value color schemes */ + seriesBy?: FieldColorSeriesByMode; +} + +/** + * @beta + */ +export type FieldColorSeriesByMode = 'min' | 'max' | 'last'; + +export const FALLBACK_COLOR = 'gray'; diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts new file mode 100644 index 0000000..2407e45 --- /dev/null +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -0,0 +1,132 @@ +import { ComponentType } from 'react'; +import { MatcherConfig, FieldConfig, Field, DataFrame, TimeZone } from '../types'; +import { InterpolateFunction } from './panel'; +import { StandardEditorProps, FieldConfigOptionsRegistry, StandardEditorContext } from '../field'; +import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; +import { OptionEditorConfig } from './options'; +import { GrafanaTheme2 } from '../themes'; + +export interface DynamicConfigValue { + id: string; + value?: any; +} + +export interface ConfigOverrideRule { + matcher: MatcherConfig; + properties: DynamicConfigValue[]; +} + +/** + * Describes config override rules created when interacting with Grafana. + * + * @internal + */ +export interface SystemConfigOverrideRule extends ConfigOverrideRule { + __systemRef: string; +} + +/** + * Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}. + * It will only return true if the {@link SystemConfigOverrideRule} has the passed systemRef. + * + * @param ref system override reference + * @internal + */ +export function isSystemOverrideWithRef(ref: string) { + return (override: ConfigOverrideRule): override is T => { + return (override as T)?.__systemRef === ref; + }; +} + +/** + * Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}. + * It will return true if the {@link SystemConfigOverrideRule} has any systemRef set. + * + * @internal + */ +export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => { + return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string'; +}; + +export interface FieldConfigSource { + // Defaults applied to all numeric fields + defaults: FieldConfig; + + // Rules to override individual values + overrides: ConfigOverrideRule[]; +} + +export interface FieldOverrideContext extends StandardEditorContext { + field?: Field; + dataFrameIndex?: number; // The index for the selected field frame +} +export interface FieldConfigEditorProps + extends Omit, 'item'> { + item: FieldConfigPropertyItem; // The property info + value: TValue; + context: FieldOverrideContext; + onChange: (value?: TValue) => void; +} + +export interface FieldOverrideEditorProps extends Omit, 'item'> { + item: FieldConfigPropertyItem; + context: FieldOverrideContext; +} + +export interface FieldConfigEditorConfig + extends OptionEditorConfig { + /** + * Function that allows specifying whether or not this field config should apply to a given field. + * @param field + */ + shouldApply?: (field: Field) => boolean; + + /** Indicates that option shoukd not be available in the Field config tab */ + hideFromDefaults?: boolean; + + /** Indicates that option should not be available for the overrides */ + hideFromOverrides?: boolean; +} + +export interface FieldConfigPropertyItem + extends OptionsEditorItem, TValue> { + // An editor that can be filled in with context info (template variables etc) + override: ComponentType>; + + /** true for plugin field config properties */ + isCustom?: boolean; + + /** Hides option from the Field config tab */ + hideFromDefaults?: boolean; + + /** Indicates that option should not be available for the overrides */ + hideFromOverrides?: boolean; + + /** Convert the override value to a well typed value */ + process: (value: any, context: FieldOverrideContext, settings?: TSettings) => TValue | undefined | null; + + /** Checks if field should be processed */ + shouldApply: (field: Field) => boolean; +} + +export interface ApplyFieldOverrideOptions { + data?: DataFrame[]; + fieldConfig: FieldConfigSource; + fieldConfigRegistry?: FieldConfigOptionsRegistry; + replaceVariables: InterpolateFunction; + theme: GrafanaTheme2; + timeZone?: TimeZone; +} + +export enum FieldConfigProperty { + Unit = 'unit', + Min = 'min', + Max = 'max', + Decimals = 'decimals', + DisplayName = 'displayName', + NoValue = 'noValue', + Thresholds = 'thresholds', + Mappings = 'mappings', + Links = 'links', + Color = 'color', +} diff --git a/packages/grafana-data/src/types/flot.ts b/packages/grafana-data/src/types/flot.ts new file mode 100644 index 0000000..d9b858c --- /dev/null +++ b/packages/grafana-data/src/types/flot.ts @@ -0,0 +1,8 @@ +export interface FlotDataPoint { + dataIndex: number; + datapoint: number[]; + pageX: number; + pageY: number; + series: any; + seriesIndex: number; +} diff --git a/packages/grafana-data/src/types/geometry.ts b/packages/grafana-data/src/types/geometry.ts new file mode 100644 index 0000000..9bb223e --- /dev/null +++ b/packages/grafana-data/src/types/geometry.ts @@ -0,0 +1,14 @@ +/** + * A coordinate on a two dimensional plane. + */ +export interface CartesianCoords2D { + x: number; + y: number; +} +/** + * 2d object dimensions. + */ +export interface Dimensions2D { + width: number; + height: number; +} diff --git a/packages/grafana-data/src/types/graph.ts b/packages/grafana-data/src/types/graph.ts new file mode 100644 index 0000000..91604f5 --- /dev/null +++ b/packages/grafana-data/src/types/graph.ts @@ -0,0 +1,31 @@ +import { DisplayValue } from './displayValue'; +import { Field } from './dataFrame'; + +export interface YAxis { + index: number; + min?: number; + tickDecimals?: number; +} + +export type GraphSeriesValue = number | null; + +/** View model projection of a series */ +export interface GraphSeriesXY { + color?: string; + data: GraphSeriesValue[][]; // [x,y][] + isVisible: boolean; + label: string; + yAxis: YAxis; + // Field with series' time values + timeField: Field; + // Field with series' values + valueField: Field; + seriesIndex: number; + timeStep: number; + + info?: DisplayValue[]; // Legend info +} + +export interface CreatePlotOverlay { + (element: JQuery, event: any, plot: { getOptions: () => { events: { manager: any } } }): any; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts new file mode 100644 index 0000000..afc9146 --- /dev/null +++ b/packages/grafana-data/src/types/index.ts @@ -0,0 +1,36 @@ +export * from './data'; +export * from './dataFrame'; +export * from './dataLink'; +export * from './dashboard'; +export * from './annotations'; +export * from './logs'; +export * from './navModel'; +export * from './select'; +export * from './time'; +export * from './thresholds'; +export * from './valueMapping'; +export * from './displayValue'; +export * from './graph'; +export * from './ScopedVars'; +export * from './transformations'; +export * from './fieldOverrides'; +export * from './vector'; +export * from './app'; +export * from './datasource'; +export * from './panel'; +export * from './plugin'; +export * from './thresholds'; +export * from './templateVars'; +export * from './fieldColor'; +export * from './theme'; +export * from './orgs'; +export * from './flot'; +export * from './trace'; +export * from './explore'; +export * from './legacyEvents'; +export * from './live'; +export * from './variables'; +export * from './geometry'; +export { isUnsignedPluginSignature } from './pluginSignature'; +export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config'; +export * from './alerts'; diff --git a/packages/grafana-data/src/types/layout.ts b/packages/grafana-data/src/types/layout.ts new file mode 100644 index 0000000..4249dc3 --- /dev/null +++ b/packages/grafana-data/src/types/layout.ts @@ -0,0 +1,6 @@ +export type LayoutMode = LayoutModes.Grid | LayoutModes.List; + +export enum LayoutModes { + Grid = 'grid', + List = 'list', +} diff --git a/packages/grafana-data/src/types/legacyEvents.ts b/packages/grafana-data/src/types/legacyEvents.ts new file mode 100644 index 0000000..d7e0909 --- /dev/null +++ b/packages/grafana-data/src/types/legacyEvents.ts @@ -0,0 +1,48 @@ +import { DataQueryError, DataQueryResponseData } from './datasource'; +import { AngularPanelMenuItem } from './panel'; +import { DataFrame } from './dataFrame'; +import { eventFactory } from '../events/eventFactory'; +import { BusEventBase, BusEventWithPayload } from '../events/types'; +import { DataHoverPayload } from '../events'; + +export type AlertPayload = [string, string?]; +export type AlertErrorPayload = [string, (string | Error)?]; + +export const AppEvents = { + alertSuccess: eventFactory('alert-success'), + alertWarning: eventFactory('alert-warning'), + alertError: eventFactory('alert-error'), +}; + +export const PanelEvents = { + refresh: eventFactory('refresh'), + componentDidMount: eventFactory('component-did-mount'), + dataReceived: eventFactory('data-received'), + dataError: eventFactory('data-error'), + dataFramesReceived: eventFactory('data-frames-received'), + dataSnapshotLoad: eventFactory('data-snapshot-load'), + editModeInitialized: eventFactory('init-edit-mode'), + initPanelActions: eventFactory('init-panel-actions'), + initialized: eventFactory('panel-initialized'), + panelTeardown: eventFactory('panel-teardown'), + render: eventFactory('render'), +}; + +/** @public */ +export interface LegacyGraphHoverEventPayload extends DataHoverPayload { + pos: any; + panel: { + id: number; + }; +} + +/** @alpha */ +export class LegacyGraphHoverEvent extends BusEventWithPayload { + static type = 'graph-hover'; +} + +/** @alpha */ +export class LegacyGraphHoverClearEvent extends BusEventBase { + static type = 'graph-hover-clear'; + payload: DataHoverPayload = { point: {} }; +} diff --git a/packages/grafana-data/src/types/live.test.ts b/packages/grafana-data/src/types/live.test.ts new file mode 100644 index 0000000..e0481a3 --- /dev/null +++ b/packages/grafana-data/src/types/live.test.ts @@ -0,0 +1,17 @@ +import { LiveChannelScope, parseLiveChannelAddress } from './live'; + +describe('parse address', () => { + it('simple address', () => { + const addr = parseLiveChannelAddress('plugin/testdata/random-flakey-stream'); + expect(addr?.scope).toBe(LiveChannelScope.Plugin); + expect(addr?.namespace).toBe('testdata'); + expect(addr?.path).toBe('random-flakey-stream'); + }); + + it('suppors full path', () => { + const addr = parseLiveChannelAddress('plugin/testdata/a/b/c/d '); + expect(addr?.scope).toBe(LiveChannelScope.Plugin); + expect(addr?.namespace).toBe('testdata'); + expect(addr?.path).toBe('a/b/c/d'); + }); +}); diff --git a/packages/grafana-data/src/types/live.ts b/packages/grafana-data/src/types/live.ts new file mode 100644 index 0000000..3c924d5 --- /dev/null +++ b/packages/grafana-data/src/types/live.ts @@ -0,0 +1,218 @@ +/** + * The channel id is defined as: + * + * ${scope}/${namespace}/${path} + * + * The scope drives how the namespace is used and controlled + * + * @alpha + */ +export enum LiveChannelScope { + DataSource = 'ds', // namespace = data source ID + Plugin = 'plugin', // namespace = plugin name (singleton works for apps too) + Grafana = 'grafana', // namespace = feature + Stream = 'stream', // namespace = id for the managed data stream +} + +/** + * The type of data to expect in a given channel + * + * @alpha + */ +export enum LiveChannelType { + DataStream = 'stream', // each message contains a batch of rows that will be appened to previous values + DataFrame = 'frame', // each message is an entire data frame and should *replace* previous content + JSON = 'json', // arbitray json message +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelConfig { + /** + * An optional description for the channel + */ + description?: string; + + /** + * What kind of data do you expect + */ + type?: LiveChannelType; + + /** + * The channel keeps track of who else is connected to the same channel + */ + hasPresence?: boolean; + + /** + * Allow users to write to the connection + */ + canPublish?: boolean; +} + +export enum LiveChannelConnectionState { + /** The connection is not yet established */ + Pending = 'pending', + /** Connected to the channel */ + Connected = 'connected', + /** Disconnected from the channel. The channel will reconnect when possible */ + Disconnected = 'disconnected', + /** Was at some point connected, and will not try to reconnect */ + Shutdown = 'shutdown', + /** Channel configuraiton was invalid and will not connect */ + Invalid = 'invalid', +} + +export enum LiveChannelEventType { + Status = 'status', + Join = 'join', + Leave = 'leave', + Message = 'message', +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelStatusEvent { + type: LiveChannelEventType.Status; + + /** + * {scope}/{namespace}/{path} + */ + id: string; + + /** + * unix millies timestamp for the last status change + */ + timestamp: number; + + /** + * flag if the channel is actively connected to the channel. + * This may be false while the connections get established or if the network is lost + * As long as the `shutdown` flag is not set, the connection will try to reestablish + */ + state: LiveChannelConnectionState; + + /** + * When joining a channel, there may be an initial packet in the subscribe method + */ + message?: any; + + /** + * The last error. + * + * This will remain in the status until a new message is successfully received from the channel + */ + error?: any; +} + +export interface LiveChannelJoinEvent { + type: LiveChannelEventType.Join; + user: any; // @alpha -- experimental -- will be filled in when we improve the UI +} + +export interface LiveChannelLeaveEvent { + type: LiveChannelEventType.Leave; + user: any; // @alpha -- experimental -- will be filled in when we improve the UI +} + +export interface LiveChannelMessageEvent { + type: LiveChannelEventType.Message; + message: T; +} + +export type LiveChannelEvent = + | LiveChannelStatusEvent + | LiveChannelJoinEvent + | LiveChannelLeaveEvent + | LiveChannelMessageEvent; + +export function isLiveChannelStatusEvent(evt: LiveChannelEvent): evt is LiveChannelStatusEvent { + return evt.type === LiveChannelEventType.Status; +} + +export function isLiveChannelJoinEvent(evt: LiveChannelEvent): evt is LiveChannelJoinEvent { + return evt.type === LiveChannelEventType.Join; +} + +export function isLiveChannelLeaveEvent(evt: LiveChannelEvent): evt is LiveChannelLeaveEvent { + return evt.type === LiveChannelEventType.Leave; +} + +export function isLiveChannelMessageEvent(evt: LiveChannelEvent): evt is LiveChannelMessageEvent { + return evt.type === LiveChannelEventType.Message; +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelPresenceStatus { + users: any; // @alpha -- experimental -- will be filled in when we improve the UI +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelAddress { + scope: LiveChannelScope; + namespace: string; // depends on the scope + path: string; +} + +/** + * Return an address from a string + * + * @alpha -- experimental + */ +export function parseLiveChannelAddress(id?: string): LiveChannelAddress | undefined { + if (id?.length) { + let parts = id.trim().split('/'); + if (parts.length >= 3) { + return { + scope: parts[0] as LiveChannelScope, + namespace: parts[1], + path: parts.slice(2).join('/'), + }; + } + } + return undefined; +} + +/** + * Check if the address has a scope, namespace, and path + * + * @alpha -- experimental + */ +export function isValidLiveChannelAddress(addr?: LiveChannelAddress): addr is LiveChannelAddress { + return !!(addr?.path && addr.namespace && addr.scope); +} + +/** + * Convert the address to an explicit channel path + * + * @alpha -- experimental + */ +export function toLiveChannelId(addr: LiveChannelAddress): string { + if (!addr.scope) { + return ''; + } + let id = addr.scope as string; + if (!addr.namespace) { + return id; + } + id += '/' + addr.namespace; + if (!addr.path) { + return id; + } + return id + '/' + addr.path; +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelSupport { + /** + * Get the channel handler for the path, or throw an error if invalid + */ + getChannelConfig(path: string): LiveChannelConfig | undefined; +} diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts new file mode 100644 index 0000000..cbc987b --- /dev/null +++ b/packages/grafana-data/src/types/logs.ts @@ -0,0 +1,145 @@ +import { Labels } from './data'; +import { GraphSeriesXY } from './graph'; +import { DataFrame } from './dataFrame'; +import { AbsoluteTimeRange } from './time'; +import { DataQuery } from './datasource'; + +/** + * Mapping of log level abbreviation to canonical log level. + * Supported levels are reduce to limit color variation. + */ +export enum LogLevel { + emerg = 'critical', + fatal = 'critical', + alert = 'critical', + crit = 'critical', + critical = 'critical', + warn = 'warning', + warning = 'warning', + err = 'error', + eror = 'error', + error = 'error', + info = 'info', + information = 'info', + informational = 'info', + notice = 'info', + dbug = 'debug', + debug = 'debug', + trace = 'trace', + unknown = 'unknown', +} + +// Used for meta information such as common labels or returned log rows in logs view in Explore +export enum LogsMetaKind { + Number, + String, + LabelsMap, + Error, +} + +export enum LogsSortOrder { + Descending = 'Descending', + Ascending = 'Ascending', +} + +export interface LogsMetaItem { + label: string; + value: string | number | Labels; + kind: LogsMetaKind; +} + +export interface LogRowModel { + // Index of the field from which the entry has been created so that we do not show it later in log row details. + entryFieldIndex: number; + + // Index of the row in the dataframe. As log rows can be stitched from multiple dataFrames, this does not have to be + // the same as rows final index when rendered. + rowIndex: number; + + // Full DataFrame from which we parsed this log. + // TODO: refactor this so we do not need to pass whole dataframes in addition to also parsed data. + dataFrame: DataFrame; + duplicates?: number; + + // Actual log line + entry: string; + hasAnsi: boolean; + hasUnescapedContent: boolean; + labels: Labels; + logLevel: LogLevel; + raw: string; + searchWords?: string[]; + timeFromNow: string; + timeEpochMs: number; + // timeEpochNs stores time with nanosecond-level precision, + // as millisecond-level precision is usually not enough for proper sorting of logs + timeEpochNs: string; + timeLocal: string; + timeUtc: string; + uid: string; + uniqueLabels?: Labels; +} + +export interface LogsModel { + hasUniqueLabels: boolean; + meta?: LogsMetaItem[]; + rows: LogRowModel[]; + series?: GraphSeriesXY[]; + visibleRange?: AbsoluteTimeRange; + queries?: DataQuery[]; +} + +export interface LogSearchMatch { + start: number; + length: number; + text: string; +} + +export interface LogLabelStatsModel { + active?: boolean; + count: number; + proportion: number; + value: string; +} + +export enum LogsDedupStrategy { + none = 'none', + exact = 'exact', + numbers = 'numbers', + signature = 'signature', +} + +export interface LogsParser { + /** + * Value-agnostic matcher for a field label. + * Used to filter rows, and first capture group contains the value. + */ + buildMatcher: (label: string) => RegExp; + + /** + * Returns all parsable substrings from a line, used for highlighting + */ + getFields: (line: string) => string[]; + + /** + * Gets the label name from a parsable substring of a line + */ + getLabelFromField: (field: string) => string; + + /** + * Gets the label value from a parsable substring of a line + */ + getValueFromField: (field: string) => string; + /** + * Function to verify if this is a valid parser for the given line. + * The parser accepts the line unless it returns undefined. + */ + test: (line: string) => any; +} + +export enum LogsDedupDescription { + none = 'No de-duplication', + exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.', + numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.', + signature = 'De-duplication of successive lines that have identical punctuation and whitespace.', +} diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts new file mode 100644 index 0000000..ff6ca48 --- /dev/null +++ b/packages/grafana-data/src/types/navModel.ts @@ -0,0 +1,42 @@ +export interface NavModelItem { + text: string; + url?: string; + subTitle?: string; + icon?: string; + img?: string; + id?: string; + active?: boolean; + hideFromTabs?: boolean; + hideFromMenu?: boolean; + divider?: boolean; + children?: NavModelItem[]; + breadcrumbs?: NavModelBreadcrumb[]; + target?: string; + parentItem?: NavModelItem; + showOrgSwitcher?: boolean; +} + +/** + * Interface used to describe different kinds of page titles and page navigation. Navmodels are usually generated in the backend and stored in Redux. + */ +export interface NavModel { + /** + * Main page. that wraps the navigation. Generate the `children` property generate tabs when used with the Page component. + */ + main: NavModelItem; + /** + * This is the current active tab/navigation. + */ + node: NavModelItem; + /** + * Describes breadcrumbs that are used in places such as data source settings., folder page and plugins page. + */ + breadcrumbs?: NavModelItem[]; +} + +export interface NavModelBreadcrumb { + title: string; + url?: string; +} + +export type NavIndex = { [s: string]: NavModelItem }; diff --git a/packages/grafana-data/src/types/options.ts b/packages/grafana-data/src/types/options.ts new file mode 100644 index 0000000..d23ceb3 --- /dev/null +++ b/packages/grafana-data/src/types/options.ts @@ -0,0 +1,55 @@ +import { DataFrame } from './dataFrame'; + +/** + * Base class for editor builders + * + * @beta + */ +export interface OptionEditorConfig { + /** + * Path of the option property to control. + * + * @example + * Given options object of a type: + * ```ts + * interface Options { + * a: { + * b: string; + * } + * } + * ``` + * + * path can be either 'a' or 'a.b'. + */ + path: (keyof TOptions & string) | string; + + /** + * Name of the option. Will be displayed in the UI as form element label. + */ + name: string; + + /** + * Description of the option. Will be displayed in the UI as form element description. + */ + description?: string; + + /** + * Custom settings of the editor. + */ + settings?: TSettings; + + /** + * Array of strings representing category of the option. First element in the array will make option render as collapsible section. + */ + category?: string[]; + + /** + * Set this value if undefined + */ + defaultValue?: TValue; + + /** + * Function that enables configuration of when option editor should be shown based on current panel option properties. + */ + showIf?: (currentOptions: TOptions, data?: DataFrame[]) => boolean | undefined; +} diff --git a/packages/grafana-data/src/types/orgs.ts b/packages/grafana-data/src/types/orgs.ts new file mode 100644 index 0000000..e4e83a0 --- /dev/null +++ b/packages/grafana-data/src/types/orgs.ts @@ -0,0 +1,11 @@ +export interface UserOrgDTO { + orgId: number; + name: string; + role: OrgRole; +} + +export enum OrgRole { + Admin = 'Admin', + Editor = 'Editor', + Viewer = 'Viewer', +} diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts new file mode 100644 index 0000000..c0904cf --- /dev/null +++ b/packages/grafana-data/src/types/panel.ts @@ -0,0 +1,196 @@ +import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; +import { PluginMeta } from './plugin'; +import { ScopedVars } from './ScopedVars'; +import { LoadingState } from './data'; +import { DataFrame } from './dataFrame'; +import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; +import { EventBus } from '../events'; +import { FieldConfigSource } from './fieldOverrides'; +import { Registry } from '../utils'; +import { StandardEditorProps } from '../field'; +import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; +import { OptionEditorConfig } from './options'; +import { AlertStateInfo } from './alerts'; + +export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; + +export interface PanelPluginMeta extends PluginMeta { + /** Indicates that panel does not issue queries */ + skipDataQuery?: boolean; + /** Indicates that panel should not be available in visualisation picker */ + hideFromList?: boolean; + /** Sort order */ + sort: number; +} + +export interface PanelData { + /** State of the data (loading, done, error, streaming) */ + state: LoadingState; + + /** Contains data frames with field overrides applied */ + series: DataFrame[]; + + /** + * This is a key that will change when the DataFrame[] structure changes. + * The revision is a useful way to know if only data has changed or data+structure + */ + structureRev?: number; + + /** A list of annotation items */ + annotations?: DataFrame[]; + + /** + * @internal + * @deprecated alertState is deprecated and will be removed when the next generation Alerting is in place + */ + alertState?: AlertStateInfo; + + /** Request contains the queries and properties sent to the datasource */ + request?: DataQueryRequest; + + /** Timing measurements */ + timings?: DataQueryTimings; + + /** Any query errors */ + error?: DataQueryError; + + /** Contains the range from the request or a shifted time range if a request uses relative time */ + timeRange: TimeRange; +} + +export interface PanelProps { + /** ID of the panel within the current dashboard */ + id: number; + + /** Result set of panel queries */ + data: PanelData; + + /** Time range of the current dashboard */ + timeRange: TimeRange; + + /** Time zone of the current dashboard */ + timeZone: TimeZone; + + /** Panel options */ + options: T; + + /** Indicates whether or not panel should be rendered transparent */ + transparent: boolean; + + /** Current width of the panel */ + width: number; + + /** Current height of the panel */ + height: number; + + /** Field options configuration */ + fieldConfig: FieldConfigSource; + + /** @internal */ + renderCounter: number; + + /** Panel title */ + title: string; + + /** EventBus */ + eventBus: EventBus; + + /** Panel options change handler */ + onOptionsChange: (options: T) => void; + + /** Field config change handler */ + onFieldConfigChange: (config: FieldConfigSource) => void; + + /** Template variables interpolation function */ + replaceVariables: InterpolateFunction; + + /** Time range change handler */ + onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void; +} + +export interface PanelEditorProps { + /** Panel options */ + options: T; + /** Panel options change handler */ + onOptionsChange: ( + options: T, + // callback can be used to run something right after update. + callback?: () => void + ) => void; + /** Result set of panel queries */ + data?: PanelData; +} + +export interface PanelModel { + /** ID of the panel within the current dashboard */ + id: number; + /** Panel options */ + options: TOptions; + /** Field options configuration */ + fieldConfig: FieldConfigSource; + /** Version of the panel plugin */ + pluginVersion?: string; + scopedVars?: ScopedVars; +} + +/** + * Called when a panel is first loaded with current panel model + */ +export type PanelMigrationHandler = (panel: PanelModel) => Partial; + +/** + * Called before a panel is initialized. Allows panel inspection for any updates before changing the panel type. + */ +export type PanelTypeChangedHandler = ( + panel: PanelModel, + prevPluginId: string, + prevOptions: Record, + prevFieldConfig: FieldConfigSource +) => Partial; + +export type PanelOptionEditorsRegistry = Registry; + +export interface PanelOptionsEditorProps extends StandardEditorProps {} + +export interface PanelOptionsEditorItem + extends OptionsEditorItem, TValue> {} + +export interface PanelOptionsEditorConfig + extends OptionEditorConfig {} + +/** + * @internal + */ +export interface PanelMenuItem { + type?: 'submenu' | 'divider'; + text: string; + iconClassName?: string; + onClick?: (event: React.MouseEvent) => void; + shortcut?: string; + href?: string; + subMenu?: PanelMenuItem[]; +} + +/** + * @internal + */ +export interface AngularPanelMenuItem { + click: Function; + icon: string; + href: string; + divider: boolean; + text: string; + shortcut: string; + submenu: any[]; +} + +export enum VizOrientation { + Auto = 'auto', + Vertical = 'vertical', + Horizontal = 'horizontal', +} + +export interface PanelPluginDataSupport { + annotations: boolean; + alertStates: boolean; +} diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts new file mode 100644 index 0000000..9d9c211 --- /dev/null +++ b/packages/grafana-data/src/types/plugin.ts @@ -0,0 +1,204 @@ +import { ComponentClass } from 'react'; +import { KeyValue } from './data'; +import { LiveChannelSupport } from './live'; + +/** Describes plugins life cycle status */ +export enum PluginState { + alpha = 'alpha', // Only included if `enable_alpha` config option is true + beta = 'beta', // Will show a warning banner + stable = 'stable', // Will not show anything + deprecated = 'deprecated', // Will continue to work -- but not show up in the options to add +} + +/** Describes {@link https://grafana.com/docs/grafana/latest/plugins | type of plugin} */ +export enum PluginType { + panel = 'panel', + datasource = 'datasource', + app = 'app', + renderer = 'renderer', +} + +/** Describes status of {@link https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/ | plugin signature} */ +export enum PluginSignatureStatus { + internal = 'internal', // core plugin, no signature + valid = 'valid', // signed and accurate MANIFEST + invalid = 'invalid', // invalid signature + modified = 'modified', // valid signature, but content mismatch + missing = 'missing', // missing signature file +} + +/** Describes level of {@link https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/#plugin-signature-levels/ | plugin signature level} */ +export enum PluginSignatureType { + grafana = 'grafana', + commercial = 'commercial', + community = 'community', + private = 'private', +} + +/** Describes error code returned from Grafana plugins API call */ +export enum PluginErrorCode { + missingSignature = 'signatureMissing', + invalidSignature = 'signatureInvalid', + modifiedSignature = 'signatureModified', +} + +/** Describes error returned from Grafana plugins API call */ +export interface PluginError { + errorCode: PluginErrorCode; + pluginId: string; +} + +export interface PluginMeta { + id: string; + name: string; + type: PluginType; + info: PluginMetaInfo; + includes?: PluginInclude[]; + state?: PluginState; + + // System.load & relative URLS + module: string; + baseUrl: string; + + // Define plugin requirements + dependencies?: PluginDependencies; + + // Filled in by the backend + jsonData?: T; + secureJsonData?: KeyValue; + enabled?: boolean; + defaultNavUrl?: string; + hasUpdate?: boolean; + enterprise?: boolean; + latestVersion?: string; + pinned?: boolean; + signature?: PluginSignatureStatus; + signatureType?: PluginSignatureType; + signatureOrg?: string; + live?: boolean; +} + +interface PluginDependencyInfo { + id: string; + name: string; + version: string; + type: PluginType; +} + +export interface PluginDependencies { + grafanaVersion: string; + plugins: PluginDependencyInfo[]; +} + +export enum PluginIncludeType { + dashboard = 'dashboard', + page = 'page', + + // Only valid for apps + panel = 'panel', + datasource = 'datasource', +} + +export interface PluginInclude { + type: PluginIncludeType; + name: string; + path?: string; + icon?: string; + + role?: string; // "Viewer", Admin, editor??? + addToNav?: boolean; // Show in the sidebar... only if type=page? + + // Angular app pages + component?: string; +} + +interface PluginMetaInfoLink { + name: string; + url: string; +} + +export interface PluginBuildInfo { + time?: number; + repo?: string; + branch?: string; + hash?: string; + number?: number; + pr?: number; +} + +export interface ScreenshotInfo { + name: string; + path: string; +} + +export interface PluginMetaInfo { + author: { + name: string; + url?: string; + }; + description: string; + links: PluginMetaInfoLink[]; + logos: { + large: string; + small: string; + }; + build?: PluginBuildInfo; + screenshots: ScreenshotInfo[]; + updated: string; + version: string; +} + +export interface PluginConfigPageProps { + plugin: GrafanaPlugin; + query: KeyValue; // The URL query parameters +} + +export interface PluginConfigPage { + title: string; // Display + icon?: string; + id: string; // Unique, in URL + + body: ComponentClass>; +} + +export class GrafanaPlugin { + // Meta is filled in by the plugin loading system + meta: T; + + // This is set if the plugin system had errors loading the plugin + loadError?: boolean; + + /** + * Live streaming support + * + * Note: `plugin.json` must also define `live: true` + */ + channelSupport?: LiveChannelSupport; + + // Config control (app/datasource) + angularConfigCtrl?: any; + + // Show configuration tabs on the plugin page + configPages?: Array>; + + // Tabs on the plugin page + addConfigPage(tab: PluginConfigPage) { + if (!this.configPages) { + this.configPages = []; + } + this.configPages.push(tab); + return this; + } + + /** + * Specify how the plugin should support paths within the live streaming environment + */ + setChannelSupport(support: LiveChannelSupport) { + this.channelSupport = support; + return this; + } + + constructor() { + this.meta = {} as T; + } +} diff --git a/packages/grafana-data/src/types/pluginSignature.ts b/packages/grafana-data/src/types/pluginSignature.ts new file mode 100644 index 0000000..214970a --- /dev/null +++ b/packages/grafana-data/src/types/pluginSignature.ts @@ -0,0 +1,11 @@ +import { PluginSignatureStatus } from './plugin'; + +/** + * Utility function to check if a plugin is unsigned. + * + * @param signature - the plugin meta signature + * @internal + */ +export function isUnsignedPluginSignature(signature?: PluginSignatureStatus) { + return signature && signature !== PluginSignatureStatus.valid && signature !== PluginSignatureStatus.internal; +} diff --git a/packages/grafana-data/src/types/queryRunner.ts b/packages/grafana-data/src/types/queryRunner.ts new file mode 100644 index 0000000..b2d0496 --- /dev/null +++ b/packages/grafana-data/src/types/queryRunner.ts @@ -0,0 +1,38 @@ +import { Observable } from 'rxjs'; +import { DataQuery, DataSourceApi } from './datasource'; +import { PanelData } from './panel'; +import { ScopedVars } from './ScopedVars'; +import { TimeRange, TimeZone } from './time'; + +/** + * Describes the options used when triggering a query via the {@link QueryRunner}. + * + * @internal + */ +export interface QueryRunnerOptions { + datasource: string | DataSourceApi | null; + queries: DataQuery[]; + panelId?: number; + dashboardId?: number; + timezone: TimeZone; + timeRange: TimeRange; + timeInfo?: string; // String description of time range for display + maxDataPoints: number; + minInterval: string | undefined | null; + scopedVars?: ScopedVars; + cacheTimeout?: string; + app?: string; +} + +/** + * Describes the QueryRunner that can used to exectue queries in e.g. app plugins. + * QueryRunner instances can be created via the {@link @grafana/runtime#createQueryRunner | createQueryRunner}. + * + * @internal + */ +export interface QueryRunner { + get(): Observable; + run(options: QueryRunnerOptions): void; + cancel(): void; + destroy(): void; +} diff --git a/packages/grafana-data/src/types/select.ts b/packages/grafana-data/src/types/select.ts new file mode 100644 index 0000000..db65906 --- /dev/null +++ b/packages/grafana-data/src/types/select.ts @@ -0,0 +1,11 @@ +/** + * Used in select elements + */ +export interface SelectableValue { + label?: string; + value?: T; + imgUrl?: string; + icon?: string; + description?: string; + [key: string]: any; +} diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts new file mode 100644 index 0000000..9a11643 --- /dev/null +++ b/packages/grafana-data/src/types/templateVars.ts @@ -0,0 +1,7 @@ +export type VariableType = 'query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system'; + +export interface VariableModel { + type: VariableType; + name: string; + label: string | null; +} diff --git a/packages/grafana-data/src/types/theme.ts b/packages/grafana-data/src/types/theme.ts new file mode 100644 index 0000000..9fd7f9d --- /dev/null +++ b/packages/grafana-data/src/types/theme.ts @@ -0,0 +1,244 @@ +import { ThemeVisualizationColors } from '../themes'; + +export enum GrafanaThemeType { + Light = 'light', + Dark = 'dark', +} + +export interface GrafanaThemeCommons { + name: string; + // TODO: not sure if should be a part of theme + breakpoints: { + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + xxl: string; + }; + typography: { + fontFamily: { + sansSerif: string; + monospace: string; + }; + size: { + base: string; + xs: string; + sm: string; + md: string; + lg: string; + }; + weight: { + light: number; + regular: number; + semibold: number; + bold: number; + }; + lineHeight: { + xs: number; //1 + sm: number; //1.1 + md: number; // 4/3 + lg: number; // 1.5 + }; + // TODO: Refactor to use size instead of custom defs + heading: { + h1: string; + h2: string; + h3: string; + h4: string; + h5: string; + h6: string; + }; + link: { + decoration: string; + hoverDecoration: string; + }; + }; + spacing: { + base: number; + insetSquishMd: string; + d: string; + xxs: string; + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + gutter: string; + + // Next-gen forms spacing variables + // TODO: Move variables definition to respective components when implementing + formSpacingBase: number; + formMargin: string; + formFieldsetMargin: string; + formInputHeight: number; + formButtonHeight: number; + formInputPaddingHorizontal: string; + // Used for icons do define spacing between icon and input field + // Applied on the right(prefix) or left(suffix) + formInputAffixPaddingHorizontal: string; + formInputMargin: string; + formLabelPadding: string; + formLabelMargin: string; + formValidationMessagePadding: string; + formValidationMessageMargin: string; + inlineFormMargin: string; + }; + border: { + radius: { + sm: string; + md: string; + lg: string; + }; + width: { + sm: string; + }; + }; + height: { + sm: number; + md: number; + lg: number; + }; + panelPadding: number; + panelHeaderHeight: number; + zIndex: { + dropdown: number; + navbarFixed: number; + sidemenu: number; + tooltip: number; + modalBackdrop: number; + modal: number; + typeahead: number; + }; +} + +export interface GrafanaTheme extends GrafanaThemeCommons { + type: GrafanaThemeType; + isDark: boolean; + isLight: boolean; + palette: { + black: string; + white: string; + dark1: string; + dark2: string; + dark3: string; + dark4: string; + dark5: string; + dark6: string; + dark7: string; + dark8: string; + dark9: string; + dark10: string; + gray1: string; + gray2: string; + gray3: string; + gray4: string; + gray5: string; + gray6: string; + gray7: string; + + // New greys palette used by next-gen form elements + gray98: string; + gray97: string; + gray95: string; + gray90: string; + gray85: string; + gray70: string; + gray60: string; + gray33: string; + gray25: string; + gray15: string; + gray10: string; + gray05: string; + + // New blues palette used by next-gen form elements + blue95: string; + blue85: string; + blue80: string; + blue77: string; + + // New reds palette used by next-gen form elements + red88: string; + + // Accent colors + redBase: string; + redShade: string; + greenBase: string; + greenShade: string; + red: string; + yellow: string; + purple: string; + orange: string; + orangeDark: string; + queryRed: string; + queryGreen: string; + queryPurple: string; + queryOrange: string; + brandPrimary: string; + brandSuccess: string; + brandWarning: string; + brandDanger: string; + + // Status colors + online: string; + warn: string; + critical: string; + }; + colors: { + bg1: string; + bg2: string; + bg3: string; + border1: string; + border2: string; + border3: string; + + bgBlue1: string; + bgBlue2: string; + + dashboardBg: string; + bodyBg: string; + panelBg: string; + panelBorder: string; + pageHeaderBg: string; + pageHeaderBorder: string; + + dropdownBg: string; + dropdownShadow: string; + dropdownOptionHoverBg: string; + + // Link colors + link: string; + linkDisabled: string; + linkHover: string; + linkExternal: string; + + // Text colors + textStrong: string; + textHeading: string; + text: string; + textSemiWeak: string; + textWeak: string; + textFaint: string; + textBlue: string; + + // Next-gen forms functional colors + formLabel: string; + formDescription: string; + formInputBg: string; + formInputBgDisabled: string; + formInputBorder: string; + formInputBorderHover: string; + formInputBorderActive: string; + formInputBorderInvalid: string; + formFocusOutline: string; + formInputText: string; + formInputDisabledText: string; + formInputPlaceholderText: string; + formValidationMessageText: string; + formValidationMessageBg: string; + }; + shadows: { + listItem: string; + }; + visualization: ThemeVisualizationColors; +} diff --git a/packages/grafana-data/src/types/thresholds.ts b/packages/grafana-data/src/types/thresholds.ts new file mode 100644 index 0000000..4b30798 --- /dev/null +++ b/packages/grafana-data/src/types/thresholds.ts @@ -0,0 +1,31 @@ +export interface Threshold { + value: number; + color: string; + /** + * Warning, Error, LowLow, Low, OK, High, HighHigh etc + */ + state?: string; +} + +/** + * Display mode + */ +export enum ThresholdsMode { + Absolute = 'absolute', + /** + * between 0 and 1 (based on min/max) + */ + Percentage = 'percentage', +} + +/** + * Config that is passed to the ThresholdsEditor + */ +export interface ThresholdsConfig { + mode: ThresholdsMode; + + /** + * Must be sorted by 'value', first value is always -Infinity + */ + steps: Threshold[]; +} diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts new file mode 100644 index 0000000..6d75574 --- /dev/null +++ b/packages/grafana-data/src/types/time.ts @@ -0,0 +1,73 @@ +import { dateTime, DateTime } from '../datetime/moment_wrapper'; + +export interface RawTimeRange { + from: DateTime | string; + to: DateTime | string; +} + +export interface TimeRange { + from: DateTime; + to: DateTime; + raw: RawTimeRange; +} + +/** + * Type to describe relative time to now in seconds. + * @internal + */ +export interface RelativeTimeRange { + from: number; + to: number; +} + +export interface AbsoluteTimeRange { + from: number; + to: number; +} + +export interface IntervalValues { + interval: string; // 10s,5m + intervalMs: number; +} + +export type TimeZoneUtc = 'utc'; +export type TimeZoneBrowser = 'browser'; +export type TimeZone = TimeZoneBrowser | TimeZoneUtc | string; + +export const DefaultTimeZone: TimeZone = 'browser'; + +export interface TimeOption { + from: string; + to: string; + display: string; +} + +export interface TimeOptions { + [key: string]: TimeOption[]; +} + +export type TimeFragment = string | DateTime; + +export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export function getDefaultTimeRange(): TimeRange { + const now = dateTime(); + + return { + from: dateTime(now).subtract(6, 'hour'), + to: now, + raw: { from: 'now-6h', to: 'now' }, + }; +} + +/** + * Returns the default realtive time range. + * + * @public + */ +export function getDefaultRelativeTimeRange(): RelativeTimeRange { + return { + from: 21600, + to: 0, + }; +} diff --git a/packages/grafana-data/src/types/trace.ts b/packages/grafana-data/src/types/trace.ts new file mode 100644 index 0000000..363584a --- /dev/null +++ b/packages/grafana-data/src/types/trace.ts @@ -0,0 +1,42 @@ +/** + * Type representing a tag in a trace span or fields of a log. + */ +export type TraceKeyValuePair = { + key: string; + value: T; +}; + +/** + * Type representing a log in a span. + */ +export type TraceLog = { + // Millisecond epoch time + timestamp: number; + fields: TraceKeyValuePair[]; +}; + +/** + * This describes the structure of the dataframe that should be returned from a tracing data source to show trace + * in a TraceView component. + */ +export interface TraceSpanRow { + traceID: string; + spanID: string; + parentSpanID: string | undefined; + operationName: string; + serviceName: string; + serviceTags: TraceKeyValuePair[]; + // Millisecond epoch time + startTime: number; + // Milliseconds + duration: number; + logs?: TraceLog[]; + + // Note: To mark spen as having error add tag error: true + tags?: TraceKeyValuePair[]; + warnings?: string[]; + stackTraces?: string[]; + + // Specify custom color of the error icon + errorIconColor?: string; +} diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts new file mode 100644 index 0000000..eac4f44 --- /dev/null +++ b/packages/grafana-data/src/types/transformations.ts @@ -0,0 +1,58 @@ +import { MonoTypeOperatorFunction } from 'rxjs'; + +import { DataFrame, Field } from './dataFrame'; +import { RegistryItemWithOptions } from '../utils/Registry'; + +/** + * Function that transform data frames (AKA transformer) + */ +export interface DataTransformerInfo extends RegistryItemWithOptions { + /** + * Function that configures transformation and returns a transformer + * @param options + */ + operator: (options: TOptions) => MonoTypeOperatorFunction; +} + +export interface DataTransformerConfig { + /** + * Unique identifier of transformer + */ + id: string; + /** + * Options to be passed to the transformer + */ + options: TOptions; +} + +export type FrameMatcher = (frame: DataFrame) => boolean; +export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; + +/** + * Value matcher type to describe the matcher function + * @public + */ +export type ValueMatcher = (valueIndex: number, field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; + +export interface FieldMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FieldMatcher; +} + +export interface FrameMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FrameMatcher; +} + +/** + * Registry item to represent all the different valu matchers supported + * in the Grafana platform. + * @public + */ +export interface ValueMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => ValueMatcher; + isApplicable: (field: Field) => boolean; + getDefaultOptions: (field: Field) => TOptions; +} +export interface MatcherConfig { + id: string; + options?: TOptions; +} diff --git a/packages/grafana-data/src/types/valueMapping.ts b/packages/grafana-data/src/types/valueMapping.ts new file mode 100644 index 0000000..9a6eda0 --- /dev/null +++ b/packages/grafana-data/src/types/valueMapping.ts @@ -0,0 +1,80 @@ +/** + * @alpha + */ +export enum MappingType { + ValueToText = 'value', // was 1 + RangeToText = 'range', // was 2 + SpecialValue = 'special', +} + +/** + * @alpha + */ +export interface ValueMappingResult { + text?: string; + color?: string; + index?: number; +} + +/** + * @alpha + */ +interface BaseValueMap { + type: MappingType; + options: T; +} + +/** + * @alpha + */ +export interface ValueMap extends BaseValueMap> { + type: MappingType.ValueToText; +} + +/** + * @alpha + */ +export interface RangeMapOptions { + from: number | null; // changed from string + to: number | null; + result: ValueMappingResult; +} + +/** + * @alpha + */ +export interface RangeMap extends BaseValueMap { + type: MappingType.RangeToText; +} + +/** + * @alpha + */ +export interface SpecialValueOptions { + match: SpecialValueMatch; + result: ValueMappingResult; +} + +/** + * @alpha + */ +export enum SpecialValueMatch { + True = 'true', + False = 'false', + Null = 'null', + NaN = 'nan', + NullAndNaN = 'null+nan', + Empty = 'empty', +} + +/** + * @alpha + */ +export interface SpecialValueMap extends BaseValueMap { + type: MappingType.SpecialValue; +} + +/** + * @alpha + */ +export type ValueMapping = ValueMap | RangeMap | SpecialValueMap; diff --git a/packages/grafana-data/src/types/variables.ts b/packages/grafana-data/src/types/variables.ts new file mode 100644 index 0000000..5c0aac2 --- /dev/null +++ b/packages/grafana-data/src/types/variables.ts @@ -0,0 +1,99 @@ +import { ComponentType } from 'react'; +import { Observable } from 'rxjs'; + +import { + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceJsonData, + DataSourceOptionsType, + DataSourceQueryType, + QueryEditorProps, +} from './datasource'; + +/** + * Enum with the different variable support types + * + * @alpha -- experimental + */ +export enum VariableSupportType { + Legacy = 'legacy', + Standard = 'standard', + Custom = 'custom', + Datasource = 'datasource', +} + +/** + * Base class for VariableSupport classes + * + * @alpha -- experimental + */ +export abstract class VariableSupportBase< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> { + abstract getType(): VariableSupportType; +} + +/** + * Extend this class in a data source plugin to use the standard query editor for Query variables + * + * @alpha -- experimental + */ +export abstract class StandardVariableSupport< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Standard; + } + + abstract toDataQuery(query: StandardVariableQuery): TQuery; + query?(request: DataQueryRequest): Observable; +} + +/** + * Extend this class in a data source plugin to use a customized query editor for Query variables + * + * @alpha -- experimental + */ +export abstract class CustomVariableSupport< + DSType extends DataSourceApi, + VariableQuery extends DataQuery = any, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Custom; + } + + abstract editor: ComponentType>; + abstract query(request: DataQueryRequest): Observable; +} + +/** + * Extend this class in a data source plugin to use the query editor in the data source plugin for Query variables + * + * @alpha -- experimental + */ +export abstract class DataSourceVariableSupport< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Datasource; + } +} + +/** + * Defines the standard DatQuery used by data source plugins that implement StandardVariableSupport + * + * @alpha -- experimental + */ +export interface StandardVariableQuery extends DataQuery { + query: string; +} diff --git a/packages/grafana-data/src/types/vector.ts b/packages/grafana-data/src/types/vector.ts new file mode 100644 index 0000000..76f2038 --- /dev/null +++ b/packages/grafana-data/src/types/vector.ts @@ -0,0 +1,35 @@ +export interface Vector { + length: number; + + /** + * Access the value by index (Like an array) + */ + get(index: number): T; + + /** + * Get the results as an array. + */ + toArray(): T[]; +} + +/** + * Apache arrow vectors are Read/Write + */ +export interface ReadWriteVector extends Vector { + set: (index: number, value: T) => void; +} + +/** + * Vector with standard manipulation functions + */ +export interface MutableVector extends ReadWriteVector { + /** + * Adds the value to the vector + */ + add: (value: T) => void; + + /** + * modifies the vector so it is now the opposite order + */ + reverse: () => void; +} diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts new file mode 100644 index 0000000..0c63d9f --- /dev/null +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -0,0 +1,239 @@ +import { FieldConfigEditorProps, FieldConfigPropertyItem, FieldConfigEditorConfig } from '../types/fieldOverrides'; +import { OptionsUIRegistryBuilder } from '../types/OptionsUIRegistryBuilder'; +import { FieldType } from '../types/dataFrame'; +import { PanelOptionsEditorConfig, PanelOptionsEditorItem } from '../types/panel'; +import { + numberOverrideProcessor, + selectOverrideProcessor, + stringOverrideProcessor, + booleanOverrideProcessor, + standardEditorsRegistry, + SelectFieldConfigSettings, + StandardEditorProps, + StringFieldConfigSettings, + NumberFieldConfigSettings, + SliderFieldConfigSettings, + identityOverrideProcessor, + UnitFieldConfigSettings, + unitOverrideProcessor, +} from '../field'; + +/** + * Fluent API for declarative creation of field config option editors + */ +export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder< + TOptions, + FieldConfigEditorProps, + FieldConfigPropertyItem +> { + addNumberInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('number').editor as any, + editor: standardEditorsRegistry.get('number').editor as any, + process: numberOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : (field) => field.type === FieldType.number, + settings: config.settings || {}, + }); + } + + addSliderInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('slider').editor as any, + editor: standardEditorsRegistry.get('slider').editor as any, + process: numberOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : (field) => field.type === FieldType.number, + settings: config.settings || {}, + }); + } + + addTextInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('text').editor as any, + editor: standardEditorsRegistry.get('text').editor as any, + process: stringOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : (field) => field.type === FieldType.string, + settings: config.settings || {}, + }); + } + + addSelect>( + config: FieldConfigEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('select').editor as any, + editor: standardEditorsRegistry.get('select').editor as any, + process: selectOverrideProcessor, + // ??? + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || { options: [] }, + }); + } + + addRadio(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('radio').editor as any, + editor: standardEditorsRegistry.get('radio').editor as any, + process: selectOverrideProcessor, + // ??? + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || { options: [] }, + }); + } + + addBooleanSwitch(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('boolean').editor as any, + override: standardEditorsRegistry.get('boolean').editor as any, + process: booleanOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } + + addColorPicker(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('color').editor as any, + override: standardEditorsRegistry.get('color').editor as any, + process: identityOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } + + addUnitPicker( + config: FieldConfigEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('unit').editor as any, + override: standardEditorsRegistry.get('unit').editor as any, + process: unitOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } +} + +/** + * Fluent API for declarative creation of panel options + */ +export class PanelOptionsEditorBuilder extends OptionsUIRegistryBuilder< + TOptions, + StandardEditorProps, + PanelOptionsEditorItem +> { + addNumberInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('number').editor as any, + }); + } + + addSliderInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('slider').editor as any, + }); + } + + addTextInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('text').editor as any, + }); + } + + addStringArray( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('strings').editor as any, + }); + } + + addSelect>( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('select').editor as any, + }); + } + + addMultiSelect>( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('multi-select').editor as any, + }); + } + + addRadio>( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('radio').editor as any, + }); + } + + addBooleanSwitch(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('boolean').editor as any, + }); + } + + addColorPicker(config: PanelOptionsEditorConfig): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('color').editor as any, + settings: config.settings || {}, + }); + } + + addTimeZonePicker(config: PanelOptionsEditorConfig): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('timezone').editor as any, + settings: config.settings || {}, + }); + } + + addUnitPicker( + config: PanelOptionsEditorConfig + ): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('unit').editor as any, + }); + } +} diff --git a/packages/grafana-data/src/utils/Registry.test.ts b/packages/grafana-data/src/utils/Registry.test.ts new file mode 100644 index 0000000..a979c2c --- /dev/null +++ b/packages/grafana-data/src/utils/Registry.test.ts @@ -0,0 +1,31 @@ +import { Registry } from './Registry'; +import { FieldReducerInfo, fieldReducers, ReducerID } from '../transformations'; + +describe('Registry', () => { + describe('selectOptions', () => { + describe('when called with current', () => { + it('then order in select.current should be same as current', () => { + const list = fieldReducers.list(); + const registry = new Registry(() => list); + const current = [ReducerID.step, ReducerID.mean, ReducerID.allIsZero, ReducerID.first, ReducerID.delta]; + const select = registry.selectOptions(current); + expect(select.current).toEqual([ + { description: 'Minimum interval between values', label: 'Step', value: 'step' }, + { description: 'Average Value', label: 'Mean', value: 'mean' }, + { description: 'All values are zero', label: 'All Zeros', value: 'allIsZero' }, + { description: 'First Value', label: 'First', value: 'first' }, + { description: 'Cumulative change in value', label: 'Delta', value: 'delta' }, + ]); + }); + + describe('when called without current', () => { + it('then it should return an empty array', () => { + const list = fieldReducers.list(); + const registry = new Registry(() => list); + const select = registry.selectOptions(); + expect(select.current).toEqual([]); + }); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/Registry.ts b/packages/grafana-data/src/utils/Registry.ts new file mode 100644 index 0000000..060d4ae --- /dev/null +++ b/packages/grafana-data/src/utils/Registry.ts @@ -0,0 +1,176 @@ +import { SelectableValue } from '../types/select'; + +export interface RegistryItem { + id: string; // Unique Key -- saved in configs + name: string; // Display Name, can change without breaking configs + description?: string; + aliasIds?: string[]; // when the ID changes, we may want backwards compatibility ('current' => 'last') + + /** + * Some extensions should not be user selectable + * like: 'all' and 'any' matchers; + */ + excludeFromPicker?: boolean; +} + +export interface RegistryItemWithOptions extends RegistryItem { + /** + * Convert the options to a string + */ + getOptionsDisplayText?: (options: TOptions) => string; + + /** + * Default options used if nothing else is specified + */ + defaultOptions?: TOptions; +} + +interface RegistrySelectInfo { + options: Array>; + current: Array>; +} + +export class Registry { + private ordered: T[] = []; + private byId = new Map(); + private initialized = false; + + constructor(private init?: () => T[]) {} + + setInit = (init: () => T[]) => { + if (this.initialized) { + throw new Error('Registry already initialized'); + } + this.init = init; + }; + + getIfExists(id: string | undefined): T | undefined { + if (!this.initialized) { + this.initialize(); + } + + if (id) { + return this.byId.get(id); + } + + return undefined; + } + + private initialize() { + if (this.init) { + for (const ext of this.init()) { + this.register(ext); + } + } + this.sort(); + this.initialized = true; + } + + get(id: string): T { + const v = this.getIfExists(id); + if (!v) { + throw new Error(`"${id}" not found in: ${this.list().map((v) => v.id)}`); + } + return v; + } + + selectOptions(current?: string[], filter?: (ext: T) => boolean): RegistrySelectInfo { + if (!this.initialized) { + this.initialize(); + } + + const select = { + options: [], + current: [], + } as RegistrySelectInfo; + + const currentOptions: Record> = {}; + if (current) { + for (const id of current) { + currentOptions[id] = {}; + } + } + + for (const ext of this.ordered) { + if (ext.excludeFromPicker) { + continue; + } + if (filter && !filter(ext)) { + continue; + } + + const option = { + value: ext.id, + label: ext.name, + description: ext.description, + }; + + select.options.push(option); + if (currentOptions[ext.id]) { + currentOptions[ext.id] = option; + } + } + + if (current) { + // this makes sure we preserve the order of ids + select.current = Object.values(currentOptions); + } + + return select; + } + + /** + * Return a list of values by ID, or all values if not specified + */ + list(ids?: any[]): T[] { + if (!this.initialized) { + this.initialize(); + } + + if (ids) { + const found: T[] = []; + for (const id of ids) { + const v = this.getIfExists(id); + if (v) { + found.push(v); + } + } + return found; + } + + return this.ordered; + } + + isEmpty(): boolean { + if (!this.initialized) { + this.initialize(); + } + + return this.ordered.length === 0; + } + + register(ext: T) { + if (this.byId.has(ext.id)) { + throw new Error('Duplicate Key:' + ext.id); + } + + this.byId.set(ext.id, ext); + this.ordered.push(ext); + + if (ext.aliasIds) { + for (const alias of ext.aliasIds) { + if (!this.byId.has(alias)) { + this.byId.set(alias, ext); + } + } + } + + if (this.initialized) { + this.sort(); + } + } + + private sort() { + // TODO sort the list + } +} diff --git a/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap b/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap new file mode 100644 index 0000000..a505486 --- /dev/null +++ b/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`read csv should get X and y 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 1", + "type": "string", + "values": Array [ + "", + "2", + "5", + "", + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 2", + "type": "number", + "values": Array [ + 1, + 3, + 6, + NaN, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 3", + "type": "number", + "values": Array [ + undefined, + 4, + undefined, + NaN, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 4", + "type": "number", + "values": Array [ + undefined, + undefined, + undefined, + 7, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; + +exports[`read csv should read csv from local file system 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "a", + "type": "number", + "values": Array [ + 10, + 40, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "b", + "type": "number", + "values": Array [ + 20, + 50, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "c", + "type": "number", + "values": Array [ + 30, + 60, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; + +exports[`read csv should read csv with headers 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object { + "unit": "ms", + }, + "labels": undefined, + "name": "a", + "type": "number", + "values": Array [ + 10, + 40, + 40, + 40, + ], + }, + Object { + "config": Object { + "unit": "lengthm", + }, + "labels": undefined, + "name": "b", + "type": "number", + "values": Array [ + 20, + 50, + 500, + 50, + ], + }, + Object { + "config": Object { + "unit": "s", + }, + "labels": undefined, + "name": "c", + "type": "boolean", + "values": Array [ + true, + false, + false, + true, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; diff --git a/packages/grafana-data/src/utils/anyToNumber.ts b/packages/grafana-data/src/utils/anyToNumber.ts new file mode 100644 index 0000000..7cb69d0 --- /dev/null +++ b/packages/grafana-data/src/utils/anyToNumber.ts @@ -0,0 +1,22 @@ +import { toNumber } from 'lodash'; + +/** + * Will return any value as a number or NaN + * + * @internal + * */ +export function anyToNumber(value: any): number { + if (typeof value === 'number') { + return value; + } + + if (value === '' || value === null || value === undefined || Array.isArray(value)) { + return NaN; // lodash calls them 0 + } + + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + + return toNumber(value); +} diff --git a/packages/grafana-data/src/utils/arrayUtils.ts b/packages/grafana-data/src/utils/arrayUtils.ts new file mode 100644 index 0000000..2ad7e7d --- /dev/null +++ b/packages/grafana-data/src/utils/arrayUtils.ts @@ -0,0 +1,6 @@ +/** @internal */ +export function moveItemImmutably(arr: T[], from: number, to: number) { + const clone = [...arr]; + Array.prototype.splice.call(clone, to, 0, Array.prototype.splice.call(clone, from, 1)[0]); + return clone; +} diff --git a/packages/grafana-data/src/utils/binaryOperators.ts b/packages/grafana-data/src/utils/binaryOperators.ts new file mode 100644 index 0000000..451b314 --- /dev/null +++ b/packages/grafana-data/src/utils/binaryOperators.ts @@ -0,0 +1,39 @@ +import { RegistryItem, Registry } from './Registry'; + +export enum BinaryOperationID { + Add = '+', + Subtract = '-', + Divide = '/', + Multiply = '*', +} + +export type BinaryOperation = (left: number, right: number) => number; + +interface BinaryOperatorInfo extends RegistryItem { + operation: BinaryOperation; +} + +export const binaryOperators = new Registry(() => { + return [ + { + id: BinaryOperationID.Add, + name: 'Add', + operation: (a: number, b: number) => a + b, + }, + { + id: BinaryOperationID.Subtract, + name: 'Subtract', + operation: (a: number, b: number) => a - b, + }, + { + id: BinaryOperationID.Multiply, + name: 'Multiply', + operation: (a: number, b: number) => a * b, + }, + { + id: BinaryOperationID.Divide, + name: 'Divide', + operation: (a: number, b: number) => a / b, + }, + ]; +}); diff --git a/packages/grafana-data/src/utils/csv.test.ts b/packages/grafana-data/src/utils/csv.test.ts new file mode 100644 index 0000000..ea5f659 --- /dev/null +++ b/packages/grafana-data/src/utils/csv.test.ts @@ -0,0 +1,167 @@ +import { CSVHeaderStyle, readCSV, toCSV } from './csv'; +import { getDataFrameRow, toDataFrameDTO } from '../dataframe/processDataFrame'; + +// Test with local CSV files +import fs from 'fs'; +import { MutableDataFrame } from '../dataframe'; +import { getDisplayProcessor } from '../field'; +import { createTheme } from '../themes'; + +describe('read csv', () => { + it('should get X and y', () => { + const text = ',1\n2,3,4\n5,6\n,,,7'; + const data = readCSV(text); + expect(data.length).toBe(1); + + const series = data[0]; + expect(series.fields.length).toBe(4); + + const rows = 4; + expect(series.length).toBe(rows); + + // Make sure everything is padded properly + for (const field of series.fields) { + expect(field.values.length).toBe(rows); + } + + const dto = toDataFrameDTO(series); + expect(dto).toMatchSnapshot(); + }); + + it('should read single string OK', () => { + const text = 'a,b,c'; + const data = readCSV(text); + expect(data.length).toBe(1); + + const series = data[0]; + expect(series.fields.length).toBe(3); + expect(series.length).toBe(0); + + expect(series.fields[0].name).toEqual('a'); + expect(series.fields[1].name).toEqual('b'); + expect(series.fields[2].name).toEqual('c'); + }); + + it('should read csv from local file system', () => { + const path = __dirname + '/testdata/simple.csv'; + expect(fs.existsSync(path)).toBeTruthy(); + + const csv = fs.readFileSync(path, 'utf8'); + const data = readCSV(csv); + expect(data.length).toBe(1); + expect(toDataFrameDTO(data[0])).toMatchSnapshot(); + }); + + it('should read csv with headers', () => { + const path = __dirname + '/testdata/withHeaders.csv'; + expect(fs.existsSync(path)).toBeTruthy(); + + const csv = fs.readFileSync(path, 'utf8'); + const data = readCSV(csv); + expect(data.length).toBe(1); + expect(toDataFrameDTO(data[0])).toMatchSnapshot(); + }); +}); + +function norm(csv: string): string { + return csv.trim().replace(/[\r]/g, ''); +} + +describe('write csv', () => { + it('should write the same CSV that we read', () => { + const firstRow = [10, 'this "has quotes" inside', true]; + const path = __dirname + '/testdata/roundtrip.csv'; + const csv = fs.readFileSync(path, 'utf8'); + const data = readCSV(csv); + const out = toCSV(data, { headerStyle: CSVHeaderStyle.full }); + expect(data.length).toBe(1); + expect(getDataFrameRow(data[0], 0)).toEqual(firstRow); + expect(data[0].fields.length).toBe(3); + expect(norm(out)).toBe(norm(csv)); + + // Keep the name even without special formatting + const again = readCSV(out); + const shorter = toCSV(again, { headerStyle: CSVHeaderStyle.name }); + + const f = readCSV(shorter); + const fields = f[0].fields; + expect(fields.length).toBe(3); + expect(getDataFrameRow(f[0], 0)).toEqual(firstRow); + expect(fields.map((f) => f.name).join(',')).toEqual('a,b,c'); // the names + }); + + it('should add Excel header given config', () => { + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'Time', values: [1598784913123, 1598784914123] }, + { name: 'Value', values: ['1234', '5678'] }, + ], + }); + + const csv = toCSV([dataFrame], { useExcelHeader: true }); + expect(csv).toMatchInlineSnapshot(` + "sep=, + \\"Time\\",\\"Value\\" + 1598784913123,1234 + 1598784914123,5678 + + " + `); + }); +}); + +describe('DataFrame to CSV', () => { + it('should escape double quotes in the field names', () => { + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'Time', values: [1589455688623] }, + // As we have traceId in message already this will shadow it. + { + name: 'Value', + values: ['1234'], + labels: { + label1: 'value1', + label2: 'value1', + }, + }, + ], + }); + + const csv = toCSV([dataFrame]); + expect(csv).toMatchInlineSnapshot(` + "\\"Time\\",\\"{label1=\\"\\"value1\\"\\", label2=\\"\\"value1\\"\\"}\\" + 1589455688623,1234 + + " + `); + }); + + it('should use field display processor if exists', () => { + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'Time', values: [1589455688623] }, + { + name: 'Value', + values: [1589455688623], + config: { + unit: 'dateTimeAsIso', + }, + }, + ], + }); + + dataFrame.fields[1].display = getDisplayProcessor({ + field: dataFrame.fields[1], + timeZone: 'utc', + theme: createTheme(), + }); + + const csv = toCSV([dataFrame]); + expect(csv).toMatchInlineSnapshot(` + "\\"Time\\",\\"Value\\" + 1589455688623,2020-05-14 11:28:08 + + " + `); + }); +}); diff --git a/packages/grafana-data/src/utils/csv.ts b/packages/grafana-data/src/utils/csv.ts new file mode 100644 index 0000000..a9a7e7d --- /dev/null +++ b/packages/grafana-data/src/utils/csv.ts @@ -0,0 +1,320 @@ +// Libraries +import Papa, { ParseConfig, Parser, ParseResult } from 'papaparse'; +import { defaults } from 'lodash'; + +// Types +import { DataFrame, Field, FieldConfig, FieldType } from '../types'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { getFieldDisplayName } from '../field'; +import { formattedValueToString } from '../valueFormats'; + +export enum CSVHeaderStyle { + full, + name, + none, +} + +// Subset of all parse options +export interface CSVConfig { + delimiter?: string; // default: "," + newline?: string; // default: "\r\n" + quoteChar?: string; // default: '"' + encoding?: string; // default: "", + useExcelHeader?: boolean; // default: false + headerStyle?: CSVHeaderStyle; +} + +export interface CSVParseCallbacks { + /** + * Get a callback before any rows are processed + * This can return a modified table to force any + * Column configurations + */ + onHeader: (fields: Field[]) => void; + + // Called after each row is read + onRow: (row: any[]) => void; +} + +export interface CSVOptions { + config?: CSVConfig; + callback?: CSVParseCallbacks; +} + +export function readCSV(csv: string, options?: CSVOptions): DataFrame[] { + return new CSVReader(options).readCSV(csv); +} + +enum ParseState { + Starting, + InHeader, + ReadingRows, +} + +export class CSVReader { + config: CSVConfig; + callback?: CSVParseCallbacks; + + state: ParseState; + data: MutableDataFrame[]; + current: MutableDataFrame; + + constructor(options?: CSVOptions) { + if (!options) { + options = {}; + } + this.config = options.config || {}; + this.callback = options.callback; + + this.current = new MutableDataFrame({ fields: [] }); + this.state = ParseState.Starting; + this.data = []; + } + + // PapaParse callback on each line + private chunk = (results: ParseResult, parser: Parser): void => { + for (let i = 0; i < results.data.length; i++) { + const line: string[] = results.data[i]; + if (line.length < 1) { + continue; + } + const first = line[0]; // null or value, papaparse does not return '' + if (first) { + // Comment or header queue + if (first.startsWith('#')) { + // Look for special header column + // #{columkey}#a,b,c + const idx = first.indexOf('#', 2); + if (idx > 0) { + const k = first.substr(1, idx - 1); + const isName = 'name' === k; + + // Simple object used to check if headers match + const headerKeys: FieldConfig = { + unit: '#', + }; + + // Check if it is a known/supported column + if (isName || headerKeys.hasOwnProperty(k)) { + // Starting a new table after reading rows + if (this.state === ParseState.ReadingRows) { + this.current = new MutableDataFrame({ fields: [] }); + this.data.push(this.current); + } + + const v = first.substr(idx + 1); + if (isName) { + this.current.addFieldFor(undefined, v); + for (let j = 1; j < line.length; j++) { + this.current.addFieldFor(undefined, line[j]); + } + } else { + const { fields } = this.current; + for (let j = 0; j < fields.length; j++) { + if (!fields[j].config) { + fields[j].config = {}; + } + const disp = fields[j].config as any; // any lets name lookup + disp[k] = j === 0 ? v : line[j]; + } + } + + this.state = ParseState.InHeader; + continue; + } + } else if (this.state === ParseState.Starting) { + this.state = ParseState.InHeader; + continue; + } + // Ignore comment lines + continue; + } + + if (this.state === ParseState.Starting) { + const type = guessFieldTypeFromValue(first); + if (type === FieldType.string) { + for (const s of line) { + this.current.addFieldFor(undefined, s); + } + this.state = ParseState.InHeader; + continue; + } + this.state = ParseState.InHeader; // fall through to read rows + } + } + + // Add the current results to the data + if (this.state !== ParseState.ReadingRows) { + // anything??? + } + + this.state = ParseState.ReadingRows; + + // Make sure column structure is valid + if (line.length > this.current.fields.length) { + const { fields } = this.current; + for (let f = fields.length; f < line.length; f++) { + this.current.addFieldFor(line[f]); + } + if (this.callback) { + this.callback.onHeader(this.current.fields); + } + } + + this.current.appendRow(line); + if (this.callback) { + // // Send the header after we guess the type + // if (this.series.rows.length === 0) { + // this.callback.onHeader(this.series); + // } + this.callback.onRow(line); + } + } + }; + + readCSV(text: string): MutableDataFrame[] { + this.current = new MutableDataFrame({ fields: [] }); + this.data = [this.current]; + + const papacfg = { + ...this.config, + dynamicTyping: false, + skipEmptyLines: true, + comments: false, // Keep comment lines + chunk: this.chunk, + } as ParseConfig; + + Papa.parse(text, papacfg); + + return this.data; + } +} + +type FieldWriter = (value: any) => string; + +function writeValue(value: any, config: CSVConfig): string { + const str = value.toString(); + if (str.includes('"')) { + // Escape the double quote characters + return config.quoteChar + str.replace(/"/gi, '""') + config.quoteChar; + } + if (str.includes('\n') || str.includes(config.delimiter)) { + return config.quoteChar + str + config.quoteChar; + } + return str; +} + +function makeFieldWriter(field: Field, config: CSVConfig): FieldWriter { + if (field.display) { + return (value: any) => { + const displayValue = field.display!(value); + return writeValue(formattedValueToString(displayValue), config); + }; + } + + return (value: any) => writeValue(value, config); +} + +function getHeaderLine(key: string, fields: Field[], config: CSVConfig): string { + const isName = 'name' === key; + const isType = 'type' === key; + + for (const f of fields) { + const display = f.config; + if (isName || isType || (display && display.hasOwnProperty(key))) { + let line = '#' + key + '#'; + for (let i = 0; i < fields.length; i++) { + if (i > 0) { + line = line + config.delimiter; + } + + let v: any = fields[i].name; + if (isType) { + v = fields[i].type; + } else if (isName) { + // already name + } else { + v = (fields[i].config as any)[key]; + } + if (v) { + line = line + writeValue(v, config); + } + } + return line + config.newline; + } + } + return ''; +} + +function getLocaleDelimiter(): string { + const arr = ['x', 'y']; + if (arr.toLocaleString) { + return arr.toLocaleString().charAt(1); + } + return ','; +} + +export function toCSV(data: DataFrame[], config?: CSVConfig): string { + if (!data) { + return ''; + } + + config = defaults(config, { + delimiter: getLocaleDelimiter(), + newline: '\r\n', + quoteChar: '"', + encoding: '', + headerStyle: CSVHeaderStyle.name, + useExcelHeader: false, + }); + let csv = config.useExcelHeader ? `sep=${config.delimiter}${config.newline}` : ''; + + for (const series of data) { + const { fields } = series; + + // ignore frames with no fields + if (fields.length === 0) { + continue; + } + + if (config.headerStyle === CSVHeaderStyle.full) { + csv = + csv + + getHeaderLine('name', fields, config) + + getHeaderLine('type', fields, config) + + getHeaderLine('unit', fields, config) + + getHeaderLine('dateFormat', fields, config); + } else if (config.headerStyle === CSVHeaderStyle.name) { + for (let i = 0; i < fields.length; i++) { + if (i > 0) { + csv += config.delimiter; + } + csv += `"${getFieldDisplayName(fields[i], series).replace(/"/g, '""')}"`; + } + csv += config.newline; + } + + const length = fields[0].values.length; + + if (length > 0) { + const writers = fields.map((field) => makeFieldWriter(field, config!)); + for (let i = 0; i < length; i++) { + for (let j = 0; j < fields.length; j++) { + if (j > 0) { + csv = csv + config.delimiter; + } + + const v = fields[j].values.get(i); + if (v !== null) { + csv = csv + writers[j](v); + } + } + csv = csv + config.newline; + } + } + csv = csv + config.newline; + } + + return csv; +} diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts new file mode 100644 index 0000000..549a69d --- /dev/null +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -0,0 +1,39 @@ +import { mapInternalLinkToExplore } from './dataLinks'; +import { FieldType } from '../types'; +import { ArrayVector } from '../vector'; + +describe('mapInternalLinkToExplore', () => { + it('creates internal link', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: '12344' }, + }, + }; + + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal, + scopedVars: {}, + range: {} as any, + field: { + name: 'test', + type: FieldType.number, + config: {}, + values: new ArrayVector([2]), + }, + replaceVariables: (val) => val, + }); + + expect(link).toEqual( + expect.objectContaining({ + title: 'dsName', + href: '/explore?left={"datasource":"dsName","queries":[{"query":"12344"}]}', + onClick: undefined, + }) + ); + }); +}); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts new file mode 100644 index 0000000..bad07da --- /dev/null +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -0,0 +1,104 @@ +import { + DataLink, + DataQuery, + Field, + InternalDataLink, + InterpolateFunction, + LinkModel, + ScopedVars, + TimeRange, +} from '../types'; +import { locationUtil } from './location'; +import { serializeStateToUrlParam } from './url'; + +export const DataLinkBuiltInVars = { + keepTime: '__url_time_range', + timeRangeFrom: '__from', + timeRangeTo: '__to', + includeVars: '__all_variables', + seriesName: '__series.name', + fieldName: '__field.name', + valueTime: '__value.time', + valueNumeric: '__value.numeric', + valueText: '__value.text', + valueRaw: '__value.raw', + // name of the calculation represented by the value + valueCalc: '__value.calc', +}; + +// We inject these because we cannot import them directly as they reside inside grafana main package. +export type LinkToExploreOptions = { + link: DataLink; + scopedVars: ScopedVars; + range: TimeRange; + field: Field; + internalLink: InternalDataLink; + onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void; + replaceVariables: InterpolateFunction; +}; + +export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkModel { + const { onClickFn, replaceVariables, link, scopedVars, range, field, internalLink } = options; + + const interpolatedQuery = interpolateQuery(link, scopedVars, replaceVariables); + const title = link.title ? link.title : internalLink.datasourceName; + + return { + title: replaceVariables(title, scopedVars), + // In this case this is meant to be internal link (opens split view by default) the href will also points + // to explore but this way you can open it in new tab. + href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range), + onClick: onClickFn + ? () => { + onClickFn({ + datasourceUid: internalLink.datasourceUid, + query: interpolatedQuery, + range, + }); + } + : undefined, + target: '_self', + origin: field, + }; +} + +/** + * Generates href for internal derived field link. + */ +function generateInternalHref(datasourceName: string, query: T, range: TimeRange): string { + return locationUtil.assureBaseUrl( + `/explore?left=${serializeStateToUrlParam({ + range: range.raw, + datasource: datasourceName, + queries: [query], + })}` + ); +} + +function interpolateQuery( + link: DataLink, + scopedVars: ScopedVars, + replaceVariables: InterpolateFunction +): T { + let stringifiedQuery = ''; + try { + stringifiedQuery = JSON.stringify(link.internal?.query || ''); + } catch (err) { + // should not happen and not much to do about this, possibly something non stringifiable in the query + console.error(err); + } + + // Replace any variables inside the query. This may not be the safest as it can also replace keys etc so may not + // actually work with every datasource query right now. + stringifiedQuery = replaceVariables(stringifiedQuery, scopedVars); + + let replacedQuery = {} as T; + try { + replacedQuery = JSON.parse(stringifiedQuery); + } catch (err) { + // again should not happen and not much to do about this, probably some issue with how we replaced the variables. + console.error(stringifiedQuery, err); + } + + return replacedQuery; +} diff --git a/packages/grafana-data/src/utils/datasource.ts b/packages/grafana-data/src/utils/datasource.ts new file mode 100644 index 0000000..6fad3a5 --- /dev/null +++ b/packages/grafana-data/src/utils/datasource.ts @@ -0,0 +1,112 @@ +import { DataSourcePluginOptionsEditorProps, SelectableValue, KeyValue, DataSourceSettings } from '../types'; + +export const onUpdateDatasourceOption = (props: DataSourcePluginOptionsEditorProps, key: keyof DataSourceSettings) => ( + event: React.SyntheticEvent +) => { + updateDatasourcePluginOption(props, key, event.currentTarget.value); +}; + +export const onUpdateDatasourceJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: K +) => (event: React.SyntheticEvent) => { + updateDatasourcePluginJsonDataOption(props, key, event.currentTarget.value); +}; + +export const onUpdateDatasourceSecureJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: string +) => (event: React.SyntheticEvent) => { + updateDatasourcePluginSecureJsonDataOption(props, key, event.currentTarget.value); +}; + +export const onUpdateDatasourceJsonDataOptionSelect = ( + props: DataSourcePluginOptionsEditorProps, + key: K +) => (selected: SelectableValue) => { + updateDatasourcePluginJsonDataOption(props, key, selected.value); +}; + +export const onUpdateDatasourceJsonDataOptionChecked = ( + props: DataSourcePluginOptionsEditorProps, + key: K +) => (event: React.SyntheticEvent) => { + updateDatasourcePluginJsonDataOption(props, key, event.currentTarget.checked); +}; + +export const onUpdateDatasourceSecureJsonDataOptionSelect = ( + props: DataSourcePluginOptionsEditorProps, + key: string +) => (selected: SelectableValue) => { + updateDatasourcePluginSecureJsonDataOption(props, key, selected.value); +}; + +export const onUpdateDatasourceResetOption = (props: DataSourcePluginOptionsEditorProps, key: string) => ( + event: React.MouseEvent +) => { + updateDatasourcePluginResetOption(props, key); +}; + +export function updateDatasourcePluginOption( + props: DataSourcePluginOptionsEditorProps, + key: keyof DataSourceSettings, + val: any +) { + const config = props.options; + + props.onOptionsChange({ + ...config, + [key]: val, + }); +} + +export const updateDatasourcePluginJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: K, + val: any +) => { + const config = props.options; + + props.onOptionsChange({ + ...config, + jsonData: { + ...config.jsonData, + [key]: val, + }, + }); +}; + +export const updateDatasourcePluginSecureJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: string, + val: any +) => { + const config = props.options; + + props.onOptionsChange({ + ...config, + secureJsonData: { + ...config.secureJsonData!, + [key]: val, + }, + }); +}; + +export const updateDatasourcePluginResetOption = ( + props: DataSourcePluginOptionsEditorProps, + key: string +) => { + const config = props.options; + + props.onOptionsChange({ + ...config, + secureJsonData: { + ...config.secureJsonData, + [key]: '', + }, + secureJsonFields: { + ...config.secureJsonFields, + [key]: false, + }, + }); +}; diff --git a/packages/grafana-data/src/utils/deprecationWarning.test.ts b/packages/grafana-data/src/utils/deprecationWarning.test.ts new file mode 100644 index 0000000..6da4f0d --- /dev/null +++ b/packages/grafana-data/src/utils/deprecationWarning.test.ts @@ -0,0 +1,34 @@ +import { deprecationWarning } from './deprecationWarning'; + +test('It should not output deprecation warnings too often', () => { + let dateNowValue = 10000000; + + const spyConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + const spyDateNow = jest.spyOn(global.Date, 'now').mockImplementation(() => dateNowValue); + // Make sure the mock works + expect(Date.now()).toEqual(dateNowValue); + expect(console.warn).toHaveBeenCalledTimes(0); + + // Call the deprecation many times + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + expect(console.warn).toHaveBeenCalledTimes(1); + + // Increment the time by 1min + dateNowValue += 60000; + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + expect(console.warn).toHaveBeenCalledTimes(2); + + deprecationWarning('file2', 'oldName', 'newName'); + deprecationWarning('file2', 'oldName', 'newName'); + deprecationWarning('file2', 'oldName', 'newName'); + expect(console.warn).toHaveBeenCalledTimes(3); + + // or restoreMocks automatically? + spyConsoleWarn.mockRestore(); + spyDateNow.mockRestore(); +}); diff --git a/packages/grafana-data/src/utils/deprecationWarning.ts b/packages/grafana-data/src/utils/deprecationWarning.ts new file mode 100644 index 0000000..b959ccd --- /dev/null +++ b/packages/grafana-data/src/utils/deprecationWarning.ts @@ -0,0 +1,17 @@ +import { KeyValue } from '../types'; + +// Avoid writing the warning message more than once every 10s +const history: KeyValue = {}; + +export const deprecationWarning = (file: string, oldName: string, newName?: string) => { + let message = `[Deprecation warning] ${file}: ${oldName} is deprecated`; + if (newName) { + message += `. Use ${newName} instead`; + } + const now = Date.now(); + const last = history[message]; + if (!last || now - last > 10000) { + console.warn(message); + history[message] = now; + } +}; diff --git a/packages/grafana-data/src/utils/docs.ts b/packages/grafana-data/src/utils/docs.ts new file mode 100644 index 0000000..14e2247 --- /dev/null +++ b/packages/grafana-data/src/utils/docs.ts @@ -0,0 +1,9 @@ +/** + * Enumeration of documentation topics + * @internal + */ +export enum DocsId { + Transformations, + FieldConfig, + FieldConfigOverrides, +} diff --git a/packages/grafana-data/src/utils/fieldParser.ts b/packages/grafana-data/src/utils/fieldParser.ts new file mode 100644 index 0000000..939f4e4 --- /dev/null +++ b/packages/grafana-data/src/utils/fieldParser.ts @@ -0,0 +1,28 @@ +import { Field, FieldType } from '../types/dataFrame'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; + +export function makeFieldParser(value: any, field: Field): (value: string) => any { + if (!field.type) { + if (field.name === 'time' || field.name === 'Time') { + field.type = FieldType.time; + } else { + field.type = guessFieldTypeFromValue(value); + } + } + + if (field.type === FieldType.number) { + return (value: string) => { + return parseFloat(value); + }; + } + + // Will convert anything that starts with "T" to true + if (field.type === FieldType.boolean) { + return (value: string) => { + return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); + }; + } + + // Just pass the string back + return (value: string) => value; +} diff --git a/packages/grafana-data/src/utils/flotPairs.test.ts b/packages/grafana-data/src/utils/flotPairs.test.ts new file mode 100644 index 0000000..69dc3e1 --- /dev/null +++ b/packages/grafana-data/src/utils/flotPairs.test.ts @@ -0,0 +1,71 @@ +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { getFlotPairs, getFlotPairsConstant } from './flotPairs'; +import { TimeRange } from '../types/time'; +import { dateTime } from '../datetime/moment_wrapper'; + +describe('getFlotPairs', () => { + const series = new MutableDataFrame({ + fields: [ + { name: 'a', values: [1, 2, 3] }, + { name: 'b', values: [100, 200, 300] }, + { name: 'c', values: ['a', 'b', 'c'] }, + ], + }); + it('should get X and y', () => { + const pairs = getFlotPairs({ + xField: series.fields[0], + yField: series.fields[1], + }); + + expect(pairs.length).toEqual(3); + expect(pairs[0].length).toEqual(2); + expect(pairs[0][0]).toEqual(1); + expect(pairs[0][1]).toEqual(100); + }); + + it('should work with strings', () => { + const pairs = getFlotPairs({ + xField: series.fields[0], + yField: series.fields[2], + }); + + expect(pairs.length).toEqual(3); + expect(pairs[0].length).toEqual(2); + expect(pairs[0][0]).toEqual(1); + expect(pairs[0][1]).toEqual('a'); + }); +}); + +describe('getFlotPairsConstant', () => { + const makeRange = (from: number, to: number): TimeRange => ({ + from: dateTime(from), + to: dateTime(to), + raw: { from: `${from}`, to: `${to}` }, + }); + + it('should return an empty series on empty data', () => { + const range: TimeRange = makeRange(0, 1); + const pairs = getFlotPairsConstant([], range); + expect(pairs).toMatchObject([]); + }); + + it('should return an empty series on missing range', () => { + const pairs = getFlotPairsConstant([], {} as TimeRange); + expect(pairs).toMatchObject([]); + }); + + it('should return an constant series for range', () => { + const range: TimeRange = makeRange(0, 1); + const pairs = getFlotPairsConstant( + [ + [2, 123], + [4, 456], + ], + range + ); + expect(pairs).toMatchObject([ + [0, 123], + [1, 123], + ]); + }); +}); diff --git a/packages/grafana-data/src/utils/flotPairs.ts b/packages/grafana-data/src/utils/flotPairs.ts new file mode 100644 index 0000000..7f99dd1 --- /dev/null +++ b/packages/grafana-data/src/utils/flotPairs.ts @@ -0,0 +1,67 @@ +import { Field } from '../types/dataFrame'; +import { NullValueMode } from '../types/data'; +import { GraphSeriesValue } from '../types/graph'; +import { TimeRange } from '../types/time'; + +// Types +// import { NullValueMode, GraphSeriesValue, Field, TimeRange } from '@grafana/data'; +export interface FlotPairsOptions { + xField: Field; + yField: Field; + nullValueMode?: NullValueMode; +} + +export function getFlotPairs({ xField, yField, nullValueMode }: FlotPairsOptions): GraphSeriesValue[][] { + const vX = xField.values; + const vY = yField.values; + const length = vX.length; + if (vY.length !== length) { + throw new Error('Unexpected field length'); + } + + const ignoreNulls = nullValueMode === NullValueMode.Ignore; + const nullAsZero = nullValueMode === NullValueMode.AsZero; + + const pairs: any[][] = []; + + for (let i = 0; i < length; i++) { + const x = vX.get(i); + let y = vY.get(i); + + if (y === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + y = 0; + } + } + + // X must be a value + if (x === null) { + continue; + } + + pairs.push([x, y]); + } + return pairs; +} + +/** + * Returns a constant series based on the first value from the provide series. + * @param seriesData Series + * @param range Start and end time for the constant series + */ +export function getFlotPairsConstant(seriesData: GraphSeriesValue[][], range: TimeRange): GraphSeriesValue[][] { + if (!range.from || !range.to || !seriesData || seriesData.length === 0) { + return []; + } + + const from = range.from.valueOf(); + const to = range.to.valueOf(); + const value = seriesData[0][1]; + return [ + [from, value], + [to, value], + ]; +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts new file mode 100644 index 0000000..7ace161 --- /dev/null +++ b/packages/grafana-data/src/utils/index.ts @@ -0,0 +1,22 @@ +import * as arrayUtils from './arrayUtils'; +export * from './Registry'; +export * from './datasource'; +export * from './deprecationWarning'; +export * from './csv'; +export * from './logs'; +export * from './labels'; +export * from './labels'; +export * from './object'; +export * from './namedColorsPalette'; +export * from './series'; +export * from './binaryOperators'; +export * from './nodeGraph'; +export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders'; +export { arrayUtils }; +export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; +export { locationUtil } from './location'; +export { urlUtil, UrlQueryMap, UrlQueryValue, serializeStateToUrlParam } from './url'; +export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks'; +export { DocsId } from './docs'; +export { makeClassES5Compatible } from './makeClassES5Compatible'; +export { anyToNumber } from './anyToNumber'; diff --git a/packages/grafana-data/src/utils/labels.test.ts b/packages/grafana-data/src/utils/labels.test.ts new file mode 100644 index 0000000..1fb84f5 --- /dev/null +++ b/packages/grafana-data/src/utils/labels.test.ts @@ -0,0 +1,78 @@ +import { parseLabels, formatLabels, findCommonLabels, findUniqueLabels, matchAllLabels } from './labels'; +import { Labels } from '../types/data'; + +describe('parseLabels()', () => { + it('returns no labels on empty labels string', () => { + expect(parseLabels('')).toEqual({}); + expect(parseLabels('{}')).toEqual({}); + }); + + it('returns labels on labels string', () => { + expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: 'bar', baz: '42' }); + }); +}); + +describe('formatLabels()', () => { + it('returns no labels on empty label set', () => { + expect(formatLabels({})).toEqual(''); + expect(formatLabels({}, 'foo')).toEqual('foo'); + }); + + it('returns label string on label set', () => { + expect(formatLabels({ foo: 'bar', baz: '42' })).toEqual('{baz="42", foo="bar"}'); + }); +}); + +describe('findCommonLabels()', () => { + it('returns no common labels on empty sets', () => { + expect(findCommonLabels([{}])).toEqual({}); + expect(findCommonLabels([{}, {}])).toEqual({}); + }); + + it('returns no common labels on differing sets', () => { + expect(findCommonLabels([{ foo: 'bar' }, {}])).toEqual({}); + expect(findCommonLabels([{}, { foo: 'bar' }])).toEqual({}); + expect(findCommonLabels([{ baz: '42' }, { foo: 'bar' }])).toEqual({}); + expect(findCommonLabels([{ foo: '42', baz: 'bar' }, { foo: 'bar' }])).toEqual({}); + }); + + it('returns the single labels set as common labels', () => { + expect(findCommonLabels([{ foo: 'bar' }])).toEqual({ foo: 'bar' }); + }); +}); + +describe('findUniqueLabels()', () => { + it('returns no uncommon labels on empty sets', () => { + expect(findUniqueLabels({}, {})).toEqual({}); + }); + + it('returns all labels given no common labels', () => { + expect(findUniqueLabels({ foo: '"bar"' }, {})).toEqual({ foo: '"bar"' }); + }); + + it('returns all labels except the common labels', () => { + expect(findUniqueLabels({ foo: '"bar"', baz: '"42"' }, { foo: '"bar"' })).toEqual({ baz: '"42"' }); + }); +}); + +describe('matchAllLabels()', () => { + it('empty labels do math', () => { + expect(matchAllLabels({}, {})).toBeTruthy(); + }); + + it('missing labels', () => { + expect(matchAllLabels({ foo: 'bar' }, {})).toBeFalsy(); + }); + + it('extra labels should match', () => { + expect(matchAllLabels({ foo: 'bar' }, { foo: 'bar', baz: '22' })).toBeTruthy(); + }); + + it('be graceful with null values (match)', () => { + expect(matchAllLabels({ foo: 'bar' })).toBeFalsy(); + }); + + it('be graceful with null values (match)', () => { + expect(matchAllLabels((undefined as unknown) as Labels, { foo: 'bar' })).toBeTruthy(); + }); +}); diff --git a/packages/grafana-data/src/utils/labels.ts b/packages/grafana-data/src/utils/labels.ts new file mode 100644 index 0000000..10e1419 --- /dev/null +++ b/packages/grafana-data/src/utils/labels.ts @@ -0,0 +1,90 @@ +import { Labels } from '../types/data'; + +/** + * Regexp to extract Prometheus-style labels + */ +const labelRegexp = /\b(\w+)(!?=~?)"([^"\n]*?)"/g; + +/** + * Returns a map of label keys to value from an input selector string. + * + * Example: `parseLabels('{job="foo", instance="bar"}) // {job: "foo", instance: "bar"}` + */ +export function parseLabels(labels: string): Labels { + const labelsByKey: Labels = {}; + labels.replace(labelRegexp, (_, key, operator, value) => { + labelsByKey[key] = value; + return ''; + }); + return labelsByKey; +} + +/** + * Returns a map labels that are common to the given label sets. + */ +export function findCommonLabels(labelsSets: Labels[]): Labels { + return labelsSets.reduce((acc, labels) => { + if (!labels) { + throw new Error('Need parsed labels to find common labels.'); + } + if (!acc) { + // Initial set + acc = { ...labels }; + } else { + // Remove incoming labels that are missing or not matching in value + Object.keys(labels).forEach((key) => { + if (acc[key] === undefined || acc[key] !== labels[key]) { + delete acc[key]; + } + }); + // Remove common labels that are missing from incoming label set + Object.keys(acc).forEach((key) => { + if (labels[key] === undefined) { + delete acc[key]; + } + }); + } + return acc; + }, (undefined as unknown) as Labels); +} + +/** + * Returns a map of labels that are in `labels`, but not in `commonLabels`. + */ +export function findUniqueLabels(labels: Labels | undefined, commonLabels: Labels): Labels { + const uncommonLabels: Labels = { ...labels }; + Object.keys(commonLabels).forEach((key) => { + delete uncommonLabels[key]; + }); + return uncommonLabels; +} + +/** + * Check that all labels exist in another set of labels + */ +export function matchAllLabels(expect: Labels, against?: Labels): boolean { + if (!expect) { + return true; // nothing to match + } + for (const [key, value] of Object.entries(expect)) { + if (!against || against[key] !== value) { + return false; + } + } + return true; +} + +/** + * Serializes the given labels to a string. + */ +export function formatLabels(labels: Labels, defaultValue = '', withoutBraces?: boolean): string { + if (!labels || Object.keys(labels).length === 0) { + return defaultValue; + } + const labelKeys = Object.keys(labels).sort(); + const cleanSelector = labelKeys.map((key) => `${key}="${labels[key]}"`).join(', '); + if (withoutBraces) { + return cleanSelector; + } + return ['{', cleanSelector, '}'].join(''); +} diff --git a/packages/grafana-data/src/utils/location.test.ts b/packages/grafana-data/src/utils/location.test.ts new file mode 100644 index 0000000..c06dc85 --- /dev/null +++ b/packages/grafana-data/src/utils/location.test.ts @@ -0,0 +1,65 @@ +import { locationUtil } from './location'; + +describe('locationUtil', () => { + const { location } = window; + + beforeAll(() => { + // @ts-ignore + delete window.location; + + window.location = { + ...location, + hash: '#hash', + host: 'www.domain.com:9877', + hostname: 'www.domain.com', + href: 'http://www.domain.com:9877/path/b?search=a&b=c&d#hash', + origin: 'http://www.domain.com:9877', + pathname: '/path/b', + port: '9877', + protocol: 'http:', + search: '?search=a&b=c&d', + }; + }); + + afterAll(() => { + window.location = location; + }); + + describe('strip base when appSubUrl configured', () => { + beforeEach(() => { + locationUtil.initialize({ + config: { appSubUrl: '/subUrl' } as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + }); + test('relative url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/grafana/'); + }); + test('absolute url url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/grafana/'); + }); + }); + + describe('strip base when appSubUrl not configured', () => { + beforeEach(() => { + locationUtil.initialize({ + config: {} as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + }); + + test('relative url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + }); + + test('absolute url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts new file mode 100644 index 0000000..9d3d610 --- /dev/null +++ b/packages/grafana-data/src/utils/location.ts @@ -0,0 +1,78 @@ +import { GrafanaConfig, RawTimeRange, ScopedVars } from '../types'; +import { UrlQueryMap, urlUtil } from './url'; +import { textUtil } from '../text'; + +let grafanaConfig: GrafanaConfig = { appSubUrl: '' } as any; +let getTimeRangeUrlParams: () => RawTimeRange; +let getVariablesUrlParams: (scopedVars?: ScopedVars) => UrlQueryMap; + +/** + * + * @param url + * @internal + */ +const stripBaseFromUrl = (url: string): string => { + const appSubUrl = grafanaConfig.appSubUrl ?? ''; + const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0; + const isAbsoluteUrl = url.startsWith('http'); + let segmentToStrip = appSubUrl; + + if (isAbsoluteUrl || !url.startsWith('/')) { + segmentToStrip = `${window.location.origin}${appSubUrl}`; + } + + return url.length > 0 && url.indexOf(segmentToStrip) !== -1 + ? url.slice(segmentToStrip.length - stripExtraChars) + : url; +}; + +/** + * + * @param url + * @internal + */ +const assureBaseUrl = (url: string): string => { + if (url.startsWith('/')) { + return `${grafanaConfig.appSubUrl}${stripBaseFromUrl(url)}`; + } + return url; +}; + +interface LocationUtilDependencies { + config: GrafanaConfig; + getTimeRangeForUrl: () => RawTimeRange; + getVariablesUrlParams: (scopedVars?: ScopedVars) => UrlQueryMap; +} + +export const locationUtil = { + /** + * + * @param getConfig + * @param getAllVariableValuesForUrl + * @param getTimeRangeForUrl + * @internal + */ + initialize: (dependencies: LocationUtilDependencies) => { + grafanaConfig = dependencies.config; + getTimeRangeUrlParams = dependencies.getTimeRangeForUrl; + getVariablesUrlParams = dependencies.getVariablesUrlParams; + }, + stripBaseFromUrl, + assureBaseUrl, + getTimeRangeUrlParams: () => { + if (!getTimeRangeUrlParams) { + return null; + } + return urlUtil.toUrlParams(getTimeRangeUrlParams()); + }, + getVariablesUrlParams: (scopedVars?: ScopedVars) => { + if (!getVariablesUrlParams) { + return null; + } + const params = getVariablesUrlParams(scopedVars); + return urlUtil.toUrlParams(params); + }, + processUrl: (url: string) => { + return grafanaConfig.disableSanitizeHtml ? url : textUtil.sanitizeUrl(url); + }, +}; diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts new file mode 100644 index 0000000..2a0b336 --- /dev/null +++ b/packages/grafana-data/src/utils/logs.test.ts @@ -0,0 +1,369 @@ +import { LogLevel, LogsModel, LogRowModel, LogsSortOrder } from '../types/logs'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { + getLogLevel, + calculateLogsLabelStats, + calculateFieldStats, + getParser, + LogsParsers, + calculateStats, + getLogLevelFromKey, + sortLogsResult, + checkLogsError, +} from './logs'; + +describe('getLoglevel()', () => { + it('returns no log level on empty line', () => { + expect(getLogLevel('')).toBe(LogLevel.unknown); + }); + + it('returns no log level on when level is part of a word', () => { + expect(getLogLevel('who warns us')).toBe(LogLevel.unknown); + }); + + it('returns same log level for long and short version', () => { + expect(getLogLevel('[Warn]')).toBe(LogLevel.warning); + expect(getLogLevel('[Warning]')).toBe(LogLevel.warning); + expect(getLogLevel('[Warn]')).toBe('warning'); + }); + + it('returns correct log level when level is capitalized', () => { + expect(getLogLevel('WARN')).toBe(LogLevel.warn); + }); + + it('returns log level on line contains a log level', () => { + expect(getLogLevel('warn: it is looking bad')).toBe(LogLevel.warn); + expect(getLogLevel('2007-12-12 12:12:12 [WARN]: it is looking bad')).toBe(LogLevel.warn); + }); + + it('returns first log level found', () => { + expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn); + expect(getLogLevel('WARN this is a non-critical message')).toBe(LogLevel.warn); + }); +}); + +describe('getLogLevelFromKey()', () => { + it('returns correct log level', () => { + expect(getLogLevelFromKey('info')).toBe(LogLevel.info); + }); + it('returns correct log level when level is capitalized', () => { + expect(getLogLevelFromKey('INFO')).toBe(LogLevel.info); + }); + it('returns unknown log level when level is integer', () => { + expect(getLogLevelFromKey(1)).toBe(LogLevel.unknown); + }); +}); + +describe('calculateLogsLabelStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateLogsLabelStats([], '')).toEqual([]); + }); + + test('should return no stats of label is not found', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); + }); + + test('should return stats for found labels', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + { + entry: 'foo 0', + labels: { + foo: 'xxx', + }, + }, + { + entry: 'foo 2', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ + { + value: 'bar', + count: 2, + }, + { + value: 'xxx', + count: 1, + }, + ]); + }); +}); + +describe('LogsParsers', () => { + describe('logfmt', () => { + const parser = LogsParsers.logfmt; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('foo=bar')).toBeTruthy(); + }); + + test('should return detected fields', () => { + expect( + parser.getFields( + 'foo=bar baz="42 + 1" msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1" time(ms)=50 label{foo}=bar' + ) + ).toEqual([ + 'foo=bar', + 'baz="42 + 1"', + 'msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"', + 'time(ms)=50', + 'label{foo}=bar', + ]); + }); + + test('should return label for field', () => { + expect(parser.getLabelFromField('foo=bar')).toBe('foo'); + expect(parser.getLabelFromField('time(ms)=50')).toBe('time(ms)'); + }); + + test('should return value for field', () => { + expect(parser.getValueFromField('foo=bar')).toBe('bar'); + expect(parser.getValueFromField('time(ms)=50')).toBe('50'); + expect( + parser.getValueFromField( + 'msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"' + ) + ).toBe('"[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = 'foo=bar'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('bar'); + }); + + test('should build a valid complex value matcher', () => { + const matcher = parser.buildMatcher('time(ms)'); + const match = 'time(ms)=50'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('50'); + }); + }); + + describe('JSON', () => { + const parser = LogsParsers.JSON; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('{"foo":"bar"}')).toBeTruthy(); + }); + + test('should return detected fields', () => { + expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo":"bar"', '"baz":42']); + }); + + test('should return detected fields for nested quotes', () => { + expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]); + }); + + test('should return label for field', () => { + expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo'); + expect(parser.getLabelFromField('"docker.memory.fail.count":0')).toBe('docker.memory.fail.count'); + }); + + test('should return value for field', () => { + expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"'); + expect(parser.getValueFromField('"foo" : 42')).toBe('42'); + expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1'); + }); + + test('should build a valid value matcher for strings', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":"bar"}'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('bar'); + }); + + test('should build a valid value matcher for integers', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":42.1}'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('42.1'); + }); + }); +}); + +describe('calculateFieldStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); + }); + + test('should return no stats if extractor does not match', () => { + const rows = [ + { + entry: 'foo=bar', + }, + ]; + + expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); + }); + + test('should return stats for found field', () => { + const rows = [ + { + entry: 'foo="42 + 1"', + }, + { + entry: 'foo=503 baz=foo', + }, + { + entry: 'foo="42 + 1"', + }, + { + entry: 't=2018-12-05T07:44:59+0000 foo=503', + }, + ]; + + expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ + { + value: '"42 + 1"', + count: 2, + }, + { + value: '503', + count: 2, + }, + ]); + }); +}); + +describe('calculateStats()', () => { + test('should return no stats for empty array', () => { + expect(calculateStats([])).toEqual([]); + }); + + test('should return correct stats', () => { + const values = ['one', 'one', null, undefined, 'two']; + expect(calculateStats(values)).toMatchObject([ + { + value: 'one', + count: 2, + proportion: 2 / 3, + }, + { + value: 'two', + count: 1, + proportion: 1 / 3, + }, + ]); + }); +}); + +describe('getParser()', () => { + test('should return no parser on empty line', () => { + expect(getParser('')).toBeUndefined(); + }); + + test('should return no parser on unknown line pattern', () => { + expect(getParser('To Be or not to be')).toBeUndefined(); + }); + + test('should return logfmt parser on key value patterns', () => { + expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); + }); + + test('should return JSON parser on JSON log lines', () => { + // TODO implement other JSON value types than string + expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); + }); +}); + +describe('sortLogsResult', () => { + const firstRow: LogRowModel = { + rowIndex: 0, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: '', + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 0, + timeEpochNs: '0', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '1', + }; + const sameAsFirstRow = firstRow; + const secondRow: LogRowModel = { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: '', + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '10000000', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }; + + describe('when called with LogsSortOrder.Descending', () => { + it('then it should sort descending', () => { + const logsResult: LogsModel = { + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, LogsSortOrder.Descending); + + expect(result).toEqual({ + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }); + }); + }); + + describe('when called with LogsSortOrder.Ascending', () => { + it('then it should sort ascending', () => { + const logsResult: LogsModel = { + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, LogsSortOrder.Ascending); + + expect(result).toEqual({ + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }); + }); + }); +}); + +describe('checkLogsError()', () => { + const log = ({ + labels: { + __error__: 'Error Message', + foo: 'boo', + }, + } as any) as LogRowModel; + test('should return correct error if error is present', () => { + expect(checkLogsError(log)).toStrictEqual({ hasError: true, errorMessage: 'Error Message' }); + }); +}); diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts new file mode 100644 index 0000000..fb3ed84 --- /dev/null +++ b/packages/grafana-data/src/utils/logs.ts @@ -0,0 +1,228 @@ +import { countBy, chain, escapeRegExp } from 'lodash'; + +import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser, LogsModel, LogsSortOrder } from '../types/logs'; +import { DataFrame, FieldType } from '../types/index'; +import { ArrayVector } from '../vector/ArrayVector'; + +// This matches: +// first a label from start of the string or first white space, then any word chars until "=" +// second either an empty quotes, or anything that starts with quote and ends with unescaped quote, +// or any non whitespace chars that do not start with quote +const LOGFMT_REGEXP = /(?:^|\s)([\w\(\)\[\]\{\}]+)=(""|(?:".*?[^\\]"|[^"\s]\S*))/; + +/** + * Returns the log level of a log line. + * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`. + * + * Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn` + */ +export function getLogLevel(line: string): LogLevel { + if (!line) { + return LogLevel.unknown; + } + let level = LogLevel.unknown; + let currentIndex: number | undefined = undefined; + + for (const key of Object.keys(LogLevel)) { + const regexp = new RegExp(`\\b${key}\\b`, 'i'); + const result = regexp.exec(line); + + if (result) { + if (currentIndex === undefined || result.index < currentIndex) { + level = (LogLevel as any)[key]; + currentIndex = result.index; + } + } + } + return level; +} + +export function getLogLevelFromKey(key: string | number): LogLevel { + const level = (LogLevel as any)[key.toString().toLowerCase()]; + if (level) { + return level; + } + + return LogLevel.unknown; +} + +export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataFrame { + const levels = new ArrayVector(); + const lines = series.fields[lineIndex]; + for (let i = 0; i < lines.values.length; i++) { + const line = lines.values.get(lineIndex); + levels.buffer.push(getLogLevel(line)); + } + + return { + ...series, // Keeps Tags, RefID etc + fields: [ + ...series.fields, + { + name: 'LogLevel', + type: FieldType.string, + values: levels, + config: {}, + }, + ], + }; +} + +export const LogsParsers: { [name: string]: LogsParser } = { + JSON: { + buildMatcher: (label) => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), + getFields: (line) => { + try { + const parsed = JSON.parse(line); + return Object.keys(parsed).map((key) => { + return `"${key}":${JSON.stringify(parsed[key])}`; + }); + } catch {} + return []; + }, + getLabelFromField: (field) => (field.match(/^"([^"]+)"\s*:/) || [])[1], + getValueFromField: (field) => (field.match(/:\s*(.*)$/) || [])[1], + test: (line) => { + try { + return JSON.parse(line); + } catch (error) {} + }, + }, + + logfmt: { + buildMatcher: (label) => new RegExp(`(?:^|\\s)${escapeRegExp(label)}=("[^"]*"|\\S+)`), + getFields: (line) => { + const fields: string[] = []; + line.replace(new RegExp(LOGFMT_REGEXP, 'g'), (substring) => { + fields.push(substring.trim()); + return ''; + }); + return fields; + }, + getLabelFromField: (field) => (field.match(LOGFMT_REGEXP) || [])[1], + getValueFromField: (field) => (field.match(LOGFMT_REGEXP) || [])[2], + test: (line) => LOGFMT_REGEXP.test(line), + }, +}; + +export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] { + // Consider only rows that satisfy the matcher + const rowsWithField = rows.filter((row) => extractor.test(row.entry)); + const rowCount = rowsWithField.length; + + // Get field value counts for eligible rows + const countsByValue = countBy(rowsWithField, (r) => { + const row: LogRowModel = r; + const match = row.entry.match(extractor); + + return match ? match[1] : null; + }); + return getSortedCounts(countsByValue, rowCount); +} + +export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter((row) => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = countBy(rowsWithLabel, (row) => (row as LogRowModel).labels[label]); + return getSortedCounts(countsByValue, rowCount); +} + +export function calculateStats(values: any[]): LogLabelStatsModel[] { + const nonEmptyValues = values.filter((value) => value !== undefined && value !== null); + const countsByValue = countBy(nonEmptyValues); + return getSortedCounts(countsByValue, nonEmptyValues.length); +} + +const getSortedCounts = (countsByValue: { [value: string]: number }, rowCount: number) => { + return chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); +}; + +export function getParser(line: string): LogsParser | undefined { + let parser; + try { + if (LogsParsers.JSON.test(line)) { + parser = LogsParsers.JSON; + } + } catch (error) {} + + if (!parser && LogsParsers.logfmt.test(line)) { + parser = LogsParsers.logfmt; + } + + return parser; +} + +export const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { + // compare milliseconds + if (a.timeEpochMs < b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs > b.timeEpochMs) { + return 1; + } + + // if milliseconds are equal, compare nanoseconds + if (a.timeEpochNs < b.timeEpochNs) { + return -1; + } + + if (a.timeEpochNs > b.timeEpochNs) { + return 1; + } + + return 0; +}; + +export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { + // compare milliseconds + if (a.timeEpochMs > b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs < b.timeEpochMs) { + return 1; + } + + // if milliseconds are equal, compare nanoseconds + if (a.timeEpochNs > b.timeEpochNs) { + return -1; + } + + if (a.timeEpochNs < b.timeEpochNs) { + return 1; + } + + return 0; +}; + +export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => { + const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : []; + return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows }; +}; + +export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) => + sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder); + +// Currently supports only error condition in Loki logs +export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorMessage?: string } => { + if (logRow.labels.__error__) { + return { + hasError: true, + errorMessage: logRow.labels.__error__, + }; + } + return { + hasError: false, + }; +}; + +export const escapeUnescapedString = (string: string) => + string.replace(/\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); diff --git a/packages/grafana-data/src/utils/makeClassES5Compatible.ts b/packages/grafana-data/src/utils/makeClassES5Compatible.ts new file mode 100644 index 0000000..a638ce2 --- /dev/null +++ b/packages/grafana-data/src/utils/makeClassES5Compatible.ts @@ -0,0 +1,16 @@ +/** + * @beta + * Proxies a ES6 class so that it can be used as a base class for an ES5 class + */ +export function makeClassES5Compatible(ES6Class: T): T { + return (new Proxy(ES6Class as any, { + // ES5 code will call it like a function using super + apply(target, self, argumentsList) { + if (typeof Reflect === 'undefined' || !Reflect.construct) { + alert('Browser is too old'); + } + + return Reflect.construct(target, argumentsList, self.constructor); + }, + }) as unknown) as T; +} diff --git a/packages/grafana-data/src/utils/namedColorsPalette.test.ts b/packages/grafana-data/src/utils/namedColorsPalette.test.ts new file mode 100644 index 0000000..2ee9ab1 --- /dev/null +++ b/packages/grafana-data/src/utils/namedColorsPalette.test.ts @@ -0,0 +1,16 @@ +import { getColorForTheme } from './namedColorsPalette'; +import { createTheme } from '../themes'; + +describe('colors', () => { + const theme = createTheme(); + + describe('getColorFromHexRgbOrName', () => { + it('returns black for unknown color', () => { + expect(getColorForTheme('aruba-sunshine', theme.v1)).toBe('aruba-sunshine'); + }); + + it('returns dark hex variant for known color if theme not specified', () => { + expect(getColorForTheme('semi-dark-blue', theme.v1)).toBe('#3274D9'); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/namedColorsPalette.ts b/packages/grafana-data/src/utils/namedColorsPalette.ts new file mode 100644 index 0000000..f3b51e8 --- /dev/null +++ b/packages/grafana-data/src/utils/namedColorsPalette.ts @@ -0,0 +1,74 @@ +import { GrafanaTheme, GrafanaThemeType } from '../types/theme'; + +/** + * @deprecated use theme.vizColors.getByName + */ +export function getColorForTheme(color: string, theme: GrafanaTheme): string { + return theme.visualization.getColorByName(color); +} + +/** + * @deprecated use getColorForTheme + */ +export function getColorFromHexRgbOrName(color: string, type?: GrafanaThemeType): string { + return 'gray'; +} + +export const classicColors = [ + '#7EB26D', // 0: pale green + '#EAB839', // 1: mustard + '#6ED0E0', // 2: light blue + '#EF843C', // 3: orange + '#E24D42', // 4: red + '#1F78C1', // 5: ocean + '#BA43A9', // 6: purple + '#705DA0', // 7: violet + '#508642', // 8: dark green + '#CCA300', // 9: dark sand + '#447EBC', + '#C15C17', + '#890F02', + '#0A437C', + '#6D1F62', + '#584477', + '#B7DBAB', + '#F4D598', + '#70DBED', + '#F9BA8F', + '#F29191', + '#82B5D8', + '#E5A8E2', + '#AEA2E0', + '#629E51', + '#E5AC0E', + '#64B0C8', + '#E0752D', + '#BF1B00', + '#0A50A1', + '#962D82', + '#614D93', + '#9AC48A', + '#F2C96D', + '#65C5DB', + '#F9934E', + '#EA6460', + '#5195CE', + '#D683CE', + '#806EB7', + '#3F6833', + '#967302', + '#2F575E', + '#99440A', + '#58140C', + '#052B51', + '#511749', + '#3F2B5B', + '#E0F9D7', + '#FCEACA', + '#CFFAFF', + '#F9E2D2', + '#FCE2DE', + '#BADFF4', + '#F9D9F9', + '#DEDAF7', +]; diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts new file mode 100644 index 0000000..03a1c48 --- /dev/null +++ b/packages/grafana-data/src/utils/nodeGraph.ts @@ -0,0 +1,12 @@ +export enum NodeGraphDataFrameFieldNames { + id = 'id', + title = 'title', + subTitle = 'subTitle', + mainStat = 'mainStat', + secondaryStat = 'secondaryStat', + source = 'source', + target = 'target', + detail = 'detail__', + arc = 'arc__', + color = 'color', +} diff --git a/packages/grafana-data/src/utils/object.ts b/packages/grafana-data/src/utils/object.ts new file mode 100644 index 0000000..e5960b2 --- /dev/null +++ b/packages/grafana-data/src/utils/object.ts @@ -0,0 +1,8 @@ +export const objRemoveUndefined = (obj: any) => { + return Object.keys(obj).reduce((acc: any, key) => { + if (obj[key] !== undefined) { + acc[key] = obj[key]; + } + return acc; + }, {}); +}; diff --git a/packages/grafana-data/src/utils/series.test.ts b/packages/grafana-data/src/utils/series.test.ts new file mode 100644 index 0000000..4ef77d8 --- /dev/null +++ b/packages/grafana-data/src/utils/series.test.ts @@ -0,0 +1,47 @@ +import { getSeriesTimeStep, hasMsResolution } from './series'; +import { Field, FieldType } from '../types'; +import { ArrayVector } from '../vector'; + +const uniformTimeField: Field = { + name: 'time', + type: FieldType.time, + values: new ArrayVector([0, 100, 200, 300]), + config: {}, +}; +const nonUniformTimeField: Field = { + name: 'time', + type: FieldType.time, + values: new ArrayVector([0, 100, 300, 350]), + config: {}, +}; + +const msResolutionTimeField: Field = { + name: 'time', + type: FieldType.time, + values: new ArrayVector([0, 1572951685007, 300, 350]), + config: {}, +}; + +describe('getSeriesTimeStep', () => { + test('uniform series', () => { + const result = getSeriesTimeStep(uniformTimeField); + expect(result).toBe(100); + }); + + test('non-uniform series', () => { + const result = getSeriesTimeStep(nonUniformTimeField); + expect(result).toBe(50); + }); +}); + +describe('hasMsResolution', () => { + test('return false if none of the timestamps is in ms', () => { + const result = hasMsResolution(uniformTimeField); + expect(result).toBeFalsy(); + }); + + test('return true if any of the timestamps is in ms', () => { + const result = hasMsResolution(msResolutionTimeField); + expect(result).toBeTruthy(); + }); +}); diff --git a/packages/grafana-data/src/utils/series.ts b/packages/grafana-data/src/utils/series.ts new file mode 100644 index 0000000..38937b9 --- /dev/null +++ b/packages/grafana-data/src/utils/series.ts @@ -0,0 +1,46 @@ +import { Field } from '../types/dataFrame'; + +/** + * Returns minimal time step from series time field + * @param timeField + */ +export const getSeriesTimeStep = (timeField: Field): number => { + let previousTime: number | undefined; + let minTimeStep: number | undefined; + let returnTimeStep = Number.MAX_VALUE; + + for (let i = 0; i < timeField.values.length; i++) { + const currentTime = timeField.values.get(i); + + if (previousTime !== undefined) { + const timeStep = currentTime - previousTime; + + if (minTimeStep === undefined) { + returnTimeStep = timeStep; + } + + if (timeStep < returnTimeStep) { + returnTimeStep = timeStep; + } + } + previousTime = currentTime; + } + return returnTimeStep; +}; + +/** + * Checks if series time field has ms resolution + * @param timeField + */ +export const hasMsResolution = (timeField: Field) => { + for (let i = 0; i < timeField.values.length; i++) { + const value = timeField.values.get(i); + if (value !== null && value !== undefined) { + const timestamp = value.toString(); + if (timestamp.length === 13 && timestamp % 1000 !== 0) { + return true; + } + } + } + return false; +}; diff --git a/packages/grafana-data/src/utils/testdata/roundtrip.csv b/packages/grafana-data/src/utils/testdata/roundtrip.csv new file mode 100644 index 0000000..68b5986 --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/roundtrip.csv @@ -0,0 +1,12 @@ +#name#a,b,c +#type#number,string,boolean +#unit#ms,,s +10,"this ""has quotes"" inside",true +20,XX,false +30,YY,false +40,ZZ,true +50,"X,Y",true +60,"X +Y",true +70,BB,false + diff --git a/packages/grafana-data/src/utils/testdata/simple.csv b/packages/grafana-data/src/utils/testdata/simple.csv new file mode 100644 index 0000000..cd48f5d --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/simple.csv @@ -0,0 +1,3 @@ +a,b,c +10,20,30 +40,50,60 diff --git a/packages/grafana-data/src/utils/testdata/testTheme.ts b/packages/grafana-data/src/utils/testdata/testTheme.ts new file mode 100644 index 0000000..f706e04 --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/testTheme.ts @@ -0,0 +1,12 @@ +import { GrafanaTheme, GrafanaThemeType } from '../../types/theme'; + +export function getTestTheme(type: GrafanaThemeType = GrafanaThemeType.Dark): GrafanaTheme { + return ({ + type, + isDark: type === GrafanaThemeType.Dark, + isLight: type === GrafanaThemeType.Light, + colors: { + panelBg: 'white', + }, + } as unknown) as GrafanaTheme; +} diff --git a/packages/grafana-data/src/utils/testdata/withHeaders.csv b/packages/grafana-data/src/utils/testdata/withHeaders.csv new file mode 100644 index 0000000..d83ef4e --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/withHeaders.csv @@ -0,0 +1,7 @@ +#name#a,b,c +#unit#ms,lengthm,s +#type#number,string,boolean +10,20,True +40,50,FALSE +"40","500",0 +40,50,1 diff --git a/packages/grafana-data/src/utils/tests/mockStandardProperties.ts b/packages/grafana-data/src/utils/tests/mockStandardProperties.ts new file mode 100644 index 0000000..0f452c5 --- /dev/null +++ b/packages/grafana-data/src/utils/tests/mockStandardProperties.ts @@ -0,0 +1,170 @@ +import { identityOverrideProcessor } from '../../field'; +import { ThresholdsMode } from '../../types'; + +export const mockStandardProperties = () => { + const title = { + id: 'displayName', + path: 'displayName', + name: 'Display name', + description: "Field's display name", + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: { + placeholder: 'none', + expandTemplateVars: true, + }, + shouldApply: () => true, + }; + + const unit = { + id: 'unit', + path: 'unit', + name: 'Unit', + description: 'Value units', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'none', + }, + + shouldApply: () => true, + }; + + const min = { + id: 'min', + path: 'min', + name: 'Min', + description: 'Minimum expected value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + }, + shouldApply: () => true, + }; + + const max = { + id: 'max', + path: 'max', + name: 'Max', + description: 'Maximum expected value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + }, + + shouldApply: () => true, + }; + + const decimals = { + id: 'decimals', + path: 'decimals', + name: 'Decimals', + description: 'Number of decimal to be shown for a value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + min: 0, + max: 15, + integer: true, + }, + + shouldApply: () => true, + }; + + const thresholds = { + id: 'thresholds', + path: 'thresholds', + name: 'Thresholds', + description: 'Manage thresholds', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: {}, + defaultValue: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 80, color: 'red' }, + ], + }, + shouldApply: () => true, + }; + + const mappings = { + id: 'mappings', + path: 'mappings', + name: 'Value mappings', + description: 'Manage value mappings', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: {}, + defaultValue: [], + shouldApply: () => true, + }; + + const noValue = { + id: 'noValue', + path: 'noValue', + name: 'No Value', + description: 'What to show when there is no value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: '-', + }, + // ??? any optionsUi with no value + shouldApply: () => true, + }; + + const links = { + id: 'links', + path: 'links', + name: 'DataLinks', + description: 'Manage date links', + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: { + placeholder: '-', + }, + shouldApply: () => true, + }; + + const color = { + id: 'color', + path: 'color', + name: 'Color', + description: 'Customise color', + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: { + placeholder: '-', + }, + shouldApply: () => true, + }; + + return [unit, min, max, decimals, title, noValue, thresholds, mappings, links, color]; +}; diff --git a/packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts b/packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts new file mode 100644 index 0000000..ac1dba0 --- /dev/null +++ b/packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts @@ -0,0 +1,16 @@ +import { standardTransformersRegistry } from '../../transformations'; +import { DataTransformerInfo } from '../../types'; + +export const mockTransformationsRegistry = (transformers: Array>) => { + standardTransformersRegistry.setInit(() => { + return transformers.map((t) => { + return { + id: t.id, + name: t.name, + transformation: t, + description: t.description, + editor: () => null, + }; + }); + }); +}; diff --git a/packages/grafana-data/src/utils/url.test.ts b/packages/grafana-data/src/utils/url.test.ts new file mode 100644 index 0000000..27f93d2 --- /dev/null +++ b/packages/grafana-data/src/utils/url.test.ts @@ -0,0 +1,57 @@ +import { urlUtil } from './url'; + +describe('toUrlParams', () => { + it('should encode object properties as url parameters', () => { + const url = urlUtil.toUrlParams({ + server: 'backend-01', + hasSpace: 'has space', + many: ['1', '2', '3'], + true: true, + number: 20, + isNull: null, + isUndefined: undefined, + }); + expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined='); + }); +}); + +describe('toUrlParams', () => { + it('should encode the same way as angularjs', () => { + const url = urlUtil.toUrlParams({ + server: ':@', + }); + expect(url).toBe('server=:@'); + }); +}); + +describe('parseKeyValue', () => { + it('should parse url search params to object', () => { + const obj = urlUtil.parseKeyValue('param=value¶m2=value2&kiosk'); + expect(obj).toEqual({ param: 'value', param2: 'value2', kiosk: true }); + }); + + it('should parse same url key multiple times to array', () => { + const obj = urlUtil.parseKeyValue('servers=A&servers=B'); + expect(obj).toEqual({ servers: ['A', 'B'] }); + }); + + it('should parse numeric params', () => { + const obj = urlUtil.parseKeyValue('num1=12&num2=12.2'); + expect(obj).toEqual({ num1: '12', num2: '12.2' }); + }); + + it('should not parse empty string as number', () => { + const obj = urlUtil.parseKeyValue('num1=&num2=12.2'); + expect(obj).toEqual({ num1: '', num2: '12.2' }); + }); + + it('should parse boolean params', () => { + const obj = urlUtil.parseKeyValue('bool1&bool2=true&bool3=false'); + expect(obj).toEqual({ bool1: true, bool2: true, bool3: false }); + }); + + it('should parse number like params as strings', () => { + const obj = urlUtil.parseKeyValue('custom=&custom1=001&custom2=002&custom3'); + expect(obj).toEqual({ custom: '', custom1: '001', custom2: '002', custom3: true }); + }); +}); diff --git a/packages/grafana-data/src/utils/url.ts b/packages/grafana-data/src/utils/url.ts new file mode 100644 index 0000000..8d6de90 --- /dev/null +++ b/packages/grafana-data/src/utils/url.ts @@ -0,0 +1,198 @@ +/** + * @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT + */ + +import { ExploreUrlState } from '../types/explore'; + +/** + * Type to represent the value of a single query variable. + * + * @public + */ +export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[] | undefined | null; + +/** + * Type to represent the values parsed from the query string. + * + * @public + */ +export type UrlQueryMap = Record; + +function renderUrl(path: string, query: UrlQueryMap | undefined): string { + if (query && Object.keys(query).length > 0) { + path += '?' + toUrlParams(query); + } + return path; +} + +function encodeURIComponentAsAngularJS(val: string, pctEncodeSpaces?: boolean) { + return encodeURIComponent(val) + .replace(/%40/gi, '@') + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%3B/gi, ';') + .replace(/%20/g, pctEncodeSpaces ? '%20' : '+'); +} + +function toUrlParams(a: any) { + const s: any[] = []; + const rbracket = /\[\]$/; + + const isArray = (obj: any) => { + return Object.prototype.toString.call(obj) === '[object Array]'; + }; + + const add = (k: string, v: any) => { + v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v; + if (typeof v !== 'boolean') { + s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true); + } else { + s[s.length] = encodeURIComponentAsAngularJS(k, true); + } + }; + + const buildParams = (prefix: string, obj: any) => { + let i, len, key; + + if (prefix) { + if (isArray(obj)) { + for (i = 0, len = obj.length; i < len; i++) { + if (rbracket.test(prefix)) { + add(prefix, obj[i]); + } else { + buildParams(prefix, obj[i]); + } + } + } else if (obj && String(obj) === '[object Object]') { + for (key in obj) { + buildParams(prefix + '[' + key + ']', obj[key]); + } + } else { + add(prefix, obj); + } + } else if (isArray(obj)) { + for (i = 0, len = obj.length; i < len; i++) { + add(obj[i].name, obj[i].value); + } + } else { + for (key in obj) { + buildParams(key, obj[key]); + } + } + return s; + }; + + return buildParams('', a).join('&'); +} + +function appendQueryToUrl(url: string, stringToAppend: string) { + if (stringToAppend !== undefined && stringToAppend !== null && stringToAppend !== '') { + const pos = url.indexOf('?'); + if (pos !== -1) { + if (url.length - pos > 1) { + url += '&'; + } + } else { + url += '?'; + } + url += stringToAppend; + } + + return url; +} + +/** + * Return search part (as object) of current url + */ +function getUrlSearchParams() { + const search = window.location.search.substring(1); + const searchParamsSegments = search.split('&'); + const params: any = {}; + for (const p of searchParamsSegments) { + const keyValuePair = p.split('='); + if (keyValuePair.length > 1) { + // key-value param + const key = decodeURIComponent(keyValuePair[0]); + const value = decodeURIComponent(keyValuePair[1]); + params[key] = value; + } else if (keyValuePair.length === 1) { + // boolean param + const key = decodeURIComponent(keyValuePair[0]); + params[key] = true; + } + } + return params; +} + +/** + * Parses an escaped url query string into key-value pairs. + * Attribution: Code dervived from https://github.com/angular/angular.js/master/src/Angular.js#L1396 + * @returns {Object.} + */ +export function parseKeyValue(keyValue: string) { + var obj: any = {}; + const parts = (keyValue || '').split('&'); + + for (let keyValue of parts) { + let splitPoint: number | undefined; + let key: string | undefined; + let val: string | undefined | boolean; + + if (keyValue) { + key = keyValue = keyValue.replace(/\+/g, '%20'); + splitPoint = keyValue.indexOf('='); + + if (splitPoint !== -1) { + key = keyValue.substring(0, splitPoint); + val = keyValue.substring(splitPoint + 1); + } + + key = tryDecodeURIComponent(key); + + if (key !== undefined) { + val = val !== undefined ? tryDecodeURIComponent(val as string) : true; + + let parsedVal: any; + if (typeof val === 'string' && val !== '') { + parsedVal = val === 'true' || val === 'false' ? val === 'true' : val; + } else { + parsedVal = val; + } + + if (!obj.hasOwnProperty(key)) { + obj[key] = isNaN(parsedVal) ? val : parsedVal; + } else if (Array.isArray(obj[key])) { + obj[key].push(val); + } else { + obj[key] = [obj[key], isNaN(parsedVal) ? val : parsedVal]; + } + } + } + } + + return obj; +} + +function tryDecodeURIComponent(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch (e) { + return undefined; + } +} + +export const urlUtil = { + renderUrl, + toUrlParams, + appendQueryToUrl, + getUrlSearchParams, + parseKeyValue, +}; + +export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { + if (compact) { + return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); + } + return JSON.stringify(urlState); +} diff --git a/packages/grafana-data/src/utils/valueMappings.test.ts b/packages/grafana-data/src/utils/valueMappings.test.ts new file mode 100644 index 0000000..f6fe0be --- /dev/null +++ b/packages/grafana-data/src/utils/valueMappings.test.ts @@ -0,0 +1,169 @@ +import { getValueMappingResult, isNumeric } from './valueMappings'; +import { ValueMapping, MappingType, SpecialValueMatch } from '../types'; + +const testSet1: ValueMapping[] = [ + { + type: MappingType.ValueToText, + options: { '11': { text: 'elva' } }, + }, + { + type: MappingType.RangeToText, + options: { + from: 1, + to: 9, + result: { text: '1-9' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 8, + to: 12, + result: { text: '8-12' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: 'it is null' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NaN, + result: { text: 'it is nan' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.True, + result: { text: 'it is true' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.False, + result: { text: 'it is false' }, + }, + }, +]; + +describe('Format value with value mappings', () => { + it('should return null with no valuemappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '10'; + + expect(getValueMappingResult(valueMappings, value)).toBeNull(); + }); + + it('should return null with no matching valuemappings', () => { + const value = '100'; + expect(getValueMappingResult(testSet1, value)).toBeNull(); + }); + + it('should return match result with string value match', () => { + const value = '11'; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: 'elva' }); + }); + + it('should return match result with number value', () => { + const value = 11; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: 'elva' }); + }); + + it('should return match result for null value', () => { + const value = null; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: 'it is null' }); + }); + + it('should return match result for undefined value', () => { + const value = undefined; + expect(getValueMappingResult(testSet1, value as any)).toEqual({ text: 'it is null' }); + }); + + it('should return match result for nan value', () => { + const value = Number.NaN; + expect(getValueMappingResult(testSet1, value as any)).toEqual({ text: 'it is nan' }); + }); + + it('should return range mapping that matches first', () => { + const value = '9'; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: '1-9' }); + }); + + it('should return correct range mapping result', () => { + const value = '12'; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: '8-12' }); + }); + + it.each` + value | expected + ${'2/0/12'} | ${{ text: 'mapped value 1' }} + ${'2/1/12'} | ${null} + ${'2:0'} | ${{ text: 'mapped value 3' }} + ${'2:1'} | ${null} + ${'20whatever'} | ${{ text: 'mapped value 2' }} + ${'20whateve'} | ${null} + ${'20'} | ${null} + ${'00020.4'} | ${null} + ${'192.168.1.1'} | ${{ text: 'mapped value ip' }} + ${'192'} | ${null} + ${'192.168'} | ${null} + ${'192.168.1'} | ${null} + ${9.9} | ${{ text: 'OK' }} + `('numeric-like text mapping, value:${value', ({ value, expected }) => { + const valueMappings: ValueMapping[] = [ + { + type: MappingType.ValueToText, + options: { + '2/0/12': { text: 'mapped value 1' }, + '20whatever': { text: 'mapped value 2' }, + '2:0': { text: 'mapped value 3' }, + '192.168.1.1': { text: 'mapped value ip' }, + '9.9': { text: 'OK' }, + }, + }, + ]; + expect(getValueMappingResult(valueMappings, value)).toEqual(expected); + }); +}); + +describe('isNumeric', () => { + it.each` + value | expected + ${123} | ${true} + ${0} | ${true} + ${'123'} | ${true} + ${'0'} | ${true} + ${' 123'} | ${true} + ${' 123 '} | ${true} + ${' 0 '} | ${true} + ${-123.4} | ${true} + ${'-123.4'} | ${true} + ${0.41} | ${true} + ${'.41'} | ${true} + ${0x12} | ${true} + ${'0x12'} | ${true} + ${'000123.4'} | ${true} + ${2e64} | ${true} + ${'2e64'} | ${true} + ${1e10000} | ${true} + ${'1e10000'} | ${true} + ${Infinity} | ${true} + ${'abc'} | ${false} + ${' '} | ${false} + ${null} | ${false} + ${undefined} | ${false} + ${NaN} | ${false} + ${''} | ${false} + ${{}} | ${false} + ${true} | ${false} + ${[]} | ${false} + `('detects numeric values', ({ value, expected }) => { + expect(isNumeric(value)).toEqual(expected); + }); +}); diff --git a/packages/grafana-data/src/utils/valueMappings.ts b/packages/grafana-data/src/utils/valueMappings.ts new file mode 100644 index 0000000..35f56dd --- /dev/null +++ b/packages/grafana-data/src/utils/valueMappings.ts @@ -0,0 +1,88 @@ +import { ValueMapping, MappingType, ValueMappingResult, SpecialValueMatch } from '../types'; + +export function getValueMappingResult(valueMappings: ValueMapping[], value: any): ValueMappingResult | null { + for (const vm of valueMappings) { + switch (vm.type) { + case MappingType.ValueToText: + if (value == null) { + continue; + } + + const result = vm.options[value]; + if (result) { + return result; + } + + break; + + case MappingType.RangeToText: + if (value == null) { + continue; + } + + const valueAsNumber = parseFloat(value as string); + if (isNaN(valueAsNumber)) { + continue; + } + + const isNumFrom = !isNaN(vm.options.from!); + if (isNumFrom && valueAsNumber < vm.options.from!) { + continue; + } + + const isNumTo = !isNaN(vm.options.to!); + if (isNumTo && valueAsNumber > vm.options.to!) { + continue; + } + + return vm.options.result; + + case MappingType.SpecialValue: + switch (vm.options.match) { + case SpecialValueMatch.Null: { + if (value == null) { + return vm.options.result; + } + break; + } + case SpecialValueMatch.NaN: { + if (isNaN(value as any)) { + return vm.options.result; + } + break; + } + case SpecialValueMatch.NullAndNaN: { + if (isNaN(value as any) || value == null) { + return vm.options.result; + } + break; + } + case SpecialValueMatch.True: { + if (value === true || value === 'true') { + return vm.options.result; + } + break; + } + case SpecialValueMatch.False: { + if (value === false || value === 'false') { + return vm.options.result; + } + break; + } + case SpecialValueMatch.Empty: { + if (value === '') { + return vm.options.result; + } + break; + } + } + } + } + + return null; +} + +// Ref https://stackoverflow.com/a/58550111 +export function isNumeric(num: any) { + return (typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) && !isNaN(num as number); +} diff --git a/packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts b/packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts new file mode 100644 index 0000000..af23b49 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts @@ -0,0 +1,41 @@ +import { toHex, toHex0x } from './arithmeticFormatters'; +import { formattedValueToString } from './valueFormats'; + +describe('hex', () => { + it('positive integer', () => { + const str = toHex(100, 0); + expect(formattedValueToString(str)).toBe('64'); + }); + it('negative integer', () => { + const str = toHex(-100, 0); + expect(formattedValueToString(str)).toBe('-64'); + }); + it('positive float', () => { + const str = toHex(50.52, 1); + expect(formattedValueToString(str)).toBe('32.8'); + }); + it('negative float', () => { + const str = toHex(-50.333, 2); + expect(formattedValueToString(str)).toBe('-32.547AE147AE14'); + }); +}); + +describe('hex 0x', () => { + it('positive integer', () => { + const str = toHex0x(7999, 0); + expect(formattedValueToString(str)).toBe('0x1F3F'); + }); + it('negative integer', () => { + const str = toHex0x(-584, 0); + expect(formattedValueToString(str)).toBe('-0x248'); + }); + + it('positive float', () => { + const str = toHex0x(74.443, 3); + expect(formattedValueToString(str)).toBe('0x4A.716872B020C4'); + }); + it('negative float', () => { + const str = toHex0x(-65.458, 1); + expect(formattedValueToString(str)).toBe('-0x41.8'); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/arithmeticFormatters.ts b/packages/grafana-data/src/valueFormats/arithmeticFormatters.ts new file mode 100644 index 0000000..6aca412 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/arithmeticFormatters.ts @@ -0,0 +1,45 @@ +import { toFixed, FormattedValue } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; + +export function toPercent(size: number, decimals: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + return { text: toFixed(size, decimals), suffix: '%' }; +} + +export function toPercentUnit(size: number, decimals: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + return { text: toFixed(100 * size, decimals), suffix: '%' }; +} + +export function toHex0x(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + const asHex = toHex(value, decimals); + if (asHex.text.substring(0, 1) === '-') { + asHex.text = '-0x' + asHex.text.substring(1); + } else { + asHex.text = '0x' + asHex.text; + } + return asHex; +} + +export function toHex(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + return { + text: parseFloat(toFixed(value, decimals)).toString(16).toUpperCase(), + }; +} + +export function sci(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + return { text: value.toExponential(decimals as number) }; +} diff --git a/packages/grafana-data/src/valueFormats/categories.ts b/packages/grafana-data/src/valueFormats/categories.ts new file mode 100644 index 0000000..79dfb95 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/categories.ts @@ -0,0 +1,402 @@ +import { locale, scaledUnits, simpleCountUnit, toFixedUnit, ValueFormatCategory, stringFormater } from './valueFormats'; +import { + dateTimeAsIso, + dateTimeAsIsoNoDateIfToday, + dateTimeAsUS, + dateTimeAsUSNoDateIfToday, + getDateTimeAsLocalFormat, + getDateTimeAsLocalFormatNoDateIfToday, + dateTimeFromNow, + toClockMilliseconds, + toClockSeconds, + toDays, + toDurationInDaysHoursMinutesSeconds, + toDurationInHoursMinutesSeconds, + toDurationInMilliseconds, + toDurationInSeconds, + toHours, + toMicroSeconds, + toMilliSeconds, + toMinutes, + toNanoSeconds, + toSeconds, + toTimeTicks, + dateTimeSystemFormatter, +} from './dateTimeFormatters'; +import { toHex, sci, toHex0x, toPercent, toPercentUnit } from './arithmeticFormatters'; +import { binaryPrefix, currency, SIPrefix } from './symbolFormatters'; + +export const getCategories = (): ValueFormatCategory[] => [ + { + name: 'Misc', + formats: [ + { name: 'none', id: 'none', fn: toFixedUnit('') }, + { name: 'String', id: 'string', fn: stringFormater }, + { + name: 'short', + id: 'short', + fn: scaledUnits(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']), + }, + { name: 'Percent (0-100)', id: 'percent', fn: toPercent }, + { name: 'Percent (0.0-1.0)', id: 'percentunit', fn: toPercentUnit }, + { name: 'Humidity (%H)', id: 'humidity', fn: toFixedUnit('%H') }, + { name: 'Decibel', id: 'dB', fn: toFixedUnit('dB') }, + { name: 'Hexadecimal (0x)', id: 'hex0x', fn: toHex0x }, + { name: 'Hexadecimal', id: 'hex', fn: toHex }, + { name: 'Scientific notation', id: 'sci', fn: sci }, + { name: 'Locale format', id: 'locale', fn: locale }, + { name: 'Pixels', id: 'pixel', fn: toFixedUnit('px') }, + ], + }, + { + name: 'Acceleration', + formats: [ + { name: 'Meters/sec²', id: 'accMS2', fn: toFixedUnit('m/sec²') }, + { name: 'Feet/sec²', id: 'accFS2', fn: toFixedUnit('f/sec²') }, + { name: 'G unit', id: 'accG', fn: toFixedUnit('g') }, + ], + }, + { + name: 'Angle', + formats: [ + { name: 'Degrees (°)', id: 'degree', fn: toFixedUnit('°') }, + { name: 'Radians', id: 'radian', fn: toFixedUnit('rad') }, + { name: 'Gradian', id: 'grad', fn: toFixedUnit('grad') }, + { name: 'Arc Minutes', id: 'arcmin', fn: toFixedUnit('arcmin') }, + { name: 'Arc Seconds', id: 'arcsec', fn: toFixedUnit('arcsec') }, + ], + }, + { + name: 'Area', + formats: [ + { name: 'Square Meters (m²)', id: 'areaM2', fn: toFixedUnit('m²') }, + { name: 'Square Feet (ft²)', id: 'areaF2', fn: toFixedUnit('ft²') }, + { name: 'Square Miles (mi²)', id: 'areaMI2', fn: toFixedUnit('mi²') }, + ], + }, + { + name: 'Computation', + formats: [ + { name: 'FLOP/s', id: 'flops', fn: SIPrefix('FLOPS') }, + { name: 'MFLOP/s', id: 'mflops', fn: SIPrefix('FLOPS', 2) }, + { name: 'GFLOP/s', id: 'gflops', fn: SIPrefix('FLOPS', 3) }, + { name: 'TFLOP/s', id: 'tflops', fn: SIPrefix('FLOPS', 4) }, + { name: 'PFLOP/s', id: 'pflops', fn: SIPrefix('FLOPS', 5) }, + { name: 'EFLOP/s', id: 'eflops', fn: SIPrefix('FLOPS', 6) }, + { name: 'ZFLOP/s', id: 'zflops', fn: SIPrefix('FLOPS', 7) }, + { name: 'YFLOP/s', id: 'yflops', fn: SIPrefix('FLOPS', 8) }, + ], + }, + { + name: 'Concentration', + formats: [ + { name: 'parts-per-million (ppm)', id: 'ppm', fn: toFixedUnit('ppm') }, + { name: 'parts-per-billion (ppb)', id: 'conppb', fn: toFixedUnit('ppb') }, + { name: 'nanogram per cubic meter (ng/m³)', id: 'conngm3', fn: toFixedUnit('ng/m³') }, + { name: 'nanogram per normal cubic meter (ng/Nm³)', id: 'conngNm3', fn: toFixedUnit('ng/Nm³') }, + { name: 'microgram per cubic meter (μg/m³)', id: 'conμgm3', fn: toFixedUnit('μg/m³') }, + { name: 'microgram per normal cubic meter (μg/Nm³)', id: 'conμgNm3', fn: toFixedUnit('μg/Nm³') }, + { name: 'milligram per cubic meter (mg/m³)', id: 'conmgm3', fn: toFixedUnit('mg/m³') }, + { name: 'milligram per normal cubic meter (mg/Nm³)', id: 'conmgNm3', fn: toFixedUnit('mg/Nm³') }, + { name: 'gram per cubic meter (g/m³)', id: 'congm3', fn: toFixedUnit('g/m³') }, + { name: 'gram per normal cubic meter (g/Nm³)', id: 'congNm3', fn: toFixedUnit('g/Nm³') }, + { name: 'milligrams per decilitre (mg/dL)', id: 'conmgdL', fn: toFixedUnit('mg/dL') }, + { name: 'millimoles per litre (mmol/L)', id: 'conmmolL', fn: toFixedUnit('mmol/L') }, + ], + }, + { + name: 'Currency', + formats: [ + { name: 'Dollars ($)', id: 'currencyUSD', fn: currency('$') }, + { name: 'Pounds (£)', id: 'currencyGBP', fn: currency('£') }, + { name: 'Euro (€)', id: 'currencyEUR', fn: currency('€') }, + { name: 'Yen (¥)', id: 'currencyJPY', fn: currency('¥') }, + { name: 'Rubles (₽)', id: 'currencyRUB', fn: currency('₽') }, + { name: 'Hryvnias (₴)', id: 'currencyUAH', fn: currency('₴') }, + { name: 'Real (R$)', id: 'currencyBRL', fn: currency('R$') }, + { name: 'Danish Krone (kr)', id: 'currencyDKK', fn: currency('kr', true) }, + { name: 'Icelandic Króna (kr)', id: 'currencyISK', fn: currency('kr', true) }, + { name: 'Norwegian Krone (kr)', id: 'currencyNOK', fn: currency('kr', true) }, + { name: 'Swedish Krona (kr)', id: 'currencySEK', fn: currency('kr', true) }, + { name: 'Czech koruna (czk)', id: 'currencyCZK', fn: currency('czk') }, + { name: 'Swiss franc (CHF)', id: 'currencyCHF', fn: currency('CHF') }, + { name: 'Polish Złoty (PLN)', id: 'currencyPLN', fn: currency('PLN') }, + { name: 'Bitcoin (฿)', id: 'currencyBTC', fn: currency('฿') }, + { name: 'Milli Bitcoin (฿)', id: 'currencymBTC', fn: currency('mBTC') }, + { name: 'Micro Bitcoin (฿)', id: 'currencyμBTC', fn: currency('μBTC') }, + { name: 'South African Rand (R)', id: 'currencyZAR', fn: currency('R') }, + { name: 'Indian Rupee (₹)', id: 'currencyINR', fn: currency('₹') }, + { name: 'South Korean Won (₩)', id: 'currencyKRW', fn: currency('₩') }, + { name: 'Indonesian Rupiah (Rp)', id: 'currencyIDR', fn: currency('Rp') }, + { name: 'Philippine Peso (PHP)', id: 'currencyPHP', fn: currency('PHP') }, + { name: 'Vietnamese Dong (VND)', id: 'currencyVND', fn: currency('đ', true) }, + ], + }, + { + name: 'Data', + formats: [ + { name: 'bytes(IEC)', id: 'bytes', fn: binaryPrefix('B') }, + { name: 'bytes(SI)', id: 'decbytes', fn: SIPrefix('B') }, + { name: 'bits(IEC)', id: 'bits', fn: binaryPrefix('b') }, + { name: 'bits(SI)', id: 'decbits', fn: SIPrefix('b') }, + { name: 'kibibytes', id: 'kbytes', fn: binaryPrefix('B', 1) }, + { name: 'kilobytes', id: 'deckbytes', fn: SIPrefix('B', 1) }, + { name: 'mebibytes', id: 'mbytes', fn: binaryPrefix('B', 2) }, + { name: 'megabytes', id: 'decmbytes', fn: SIPrefix('B', 2) }, + { name: 'gibibytes', id: 'gbytes', fn: binaryPrefix('B', 3) }, + { name: 'gigabytes', id: 'decgbytes', fn: SIPrefix('B', 3) }, + { name: 'tebibytes', id: 'tbytes', fn: binaryPrefix('B', 4) }, + { name: 'terabytes', id: 'dectbytes', fn: SIPrefix('B', 4) }, + { name: 'pebibytes', id: 'pbytes', fn: binaryPrefix('B', 5) }, + { name: 'petabytes', id: 'decpbytes', fn: SIPrefix('B', 5) }, + ], + }, + { + name: 'Data rate', + formats: [ + { name: 'packets/sec', id: 'pps', fn: SIPrefix('p/s') }, + { name: 'bytes/sec(IEC)', id: 'binBps', fn: binaryPrefix('B/s') }, + { name: 'bytes/sec(SI)', id: 'Bps', fn: SIPrefix('B/s') }, + { name: 'bits/sec(IEC)', id: 'binbps', fn: binaryPrefix('b/s') }, + { name: 'bits/sec(SI)', id: 'bps', fn: SIPrefix('b/s') }, + { name: 'kibibytes/sec', id: 'KiBs', fn: binaryPrefix('B/s', 1) }, + { name: 'kibibits/sec', id: 'Kibits', fn: binaryPrefix('b/s', 1) }, + { name: 'kilobytes/sec', id: 'KBs', fn: SIPrefix('B/s', 1) }, + { name: 'kilobits/sec', id: 'Kbits', fn: SIPrefix('b/s', 1) }, + { name: 'mibibytes/sec', id: 'MiBs', fn: binaryPrefix('B/s', 2) }, + { name: 'mibibits/sec', id: 'Mibits', fn: binaryPrefix('b/s', 2) }, + { name: 'megabytes/sec', id: 'MBs', fn: SIPrefix('B/s', 2) }, + { name: 'megabits/sec', id: 'Mbits', fn: SIPrefix('b/s', 2) }, + { name: 'gibibytes/sec', id: 'GiBs', fn: binaryPrefix('B/s', 3) }, + { name: 'gibibits/sec', id: 'Gibits', fn: binaryPrefix('b/s', 3) }, + { name: 'gigabytes/sec', id: 'GBs', fn: SIPrefix('B/s', 3) }, + { name: 'gigabits/sec', id: 'Gbits', fn: SIPrefix('b/s', 3) }, + { name: 'tebibytes/sec', id: 'TiBs', fn: binaryPrefix('B/s', 4) }, + { name: 'tebibits/sec', id: 'Tibits', fn: binaryPrefix('b/s', 4) }, + { name: 'terabytes/sec', id: 'TBs', fn: SIPrefix('B/s', 4) }, + { name: 'terabits/sec', id: 'Tbits', fn: SIPrefix('b/s', 4) }, + { name: 'petibytes/sec', id: 'PiBs', fn: binaryPrefix('B/s', 5) }, + { name: 'petibits/sec', id: 'Pibits', fn: binaryPrefix('b/s', 5) }, + { name: 'petabytes/sec', id: 'PBs', fn: SIPrefix('B/s', 5) }, + { name: 'petabits/sec', id: 'Pbits', fn: SIPrefix('b/s', 5) }, + ], + }, + { + name: 'Date & time', + formats: [ + { name: 'Datetime ISO', id: 'dateTimeAsIso', fn: dateTimeAsIso }, + { name: 'Datetime ISO (No date if today)', id: 'dateTimeAsIsoNoDateIfToday', fn: dateTimeAsIsoNoDateIfToday }, + { name: 'Datetime US', id: 'dateTimeAsUS', fn: dateTimeAsUS }, + { name: 'Datetime US (No date if today)', id: 'dateTimeAsUSNoDateIfToday', fn: dateTimeAsUSNoDateIfToday }, + { name: 'Datetime local', id: 'dateTimeAsLocal', fn: getDateTimeAsLocalFormat() }, + { + name: 'Datetime local (No date if today)', + id: 'dateTimeAsLocalNoDateIfToday', + fn: getDateTimeAsLocalFormatNoDateIfToday(), + }, + { name: 'Datetime default', id: 'dateTimeAsSystem', fn: dateTimeSystemFormatter }, + { name: 'From Now', id: 'dateTimeFromNow', fn: dateTimeFromNow }, + ], + }, + { + name: 'Energy', + formats: [ + { name: 'Watt (W)', id: 'watt', fn: SIPrefix('W') }, + { name: 'Kilowatt (kW)', id: 'kwatt', fn: SIPrefix('W', 1) }, + { name: 'Megawatt (MW)', id: 'megwatt', fn: SIPrefix('W', 2) }, + { name: 'Gigawatt (GW)', id: 'gwatt', fn: SIPrefix('W', 3) }, + { name: 'Milliwatt (mW)', id: 'mwatt', fn: SIPrefix('W', -1) }, + { name: 'Watt per square meter (W/m²)', id: 'Wm2', fn: toFixedUnit('W/m²') }, + { name: 'Volt-ampere (VA)', id: 'voltamp', fn: SIPrefix('VA') }, + { name: 'Kilovolt-ampere (kVA)', id: 'kvoltamp', fn: SIPrefix('VA', 1) }, + { name: 'Volt-ampere reactive (var)', id: 'voltampreact', fn: SIPrefix('var') }, + { name: 'Kilovolt-ampere reactive (kVAr)', id: 'kvoltampreact', fn: SIPrefix('VAr', 1) }, + { name: 'Watt-hour (Wh)', id: 'watth', fn: SIPrefix('Wh') }, + { name: 'Watt-hour per Kilogram (Wh/kg)', id: 'watthperkg', fn: SIPrefix('Wh/kg') }, + { name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: SIPrefix('Wh', 1) }, + { name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: SIPrefix('W-Min', 1) }, + { name: 'Ampere-hour (Ah)', id: 'amph', fn: SIPrefix('Ah') }, + { name: 'Kiloampere-hour (kAh)', id: 'kamph', fn: SIPrefix('Ah', 1) }, + { name: 'Milliampere-hour (mAh)', id: 'mamph', fn: SIPrefix('Ah', -1) }, + { name: 'Joule (J)', id: 'joule', fn: SIPrefix('J') }, + { name: 'Electron volt (eV)', id: 'ev', fn: SIPrefix('eV') }, + { name: 'Ampere (A)', id: 'amp', fn: SIPrefix('A') }, + { name: 'Kiloampere (kA)', id: 'kamp', fn: SIPrefix('A', 1) }, + { name: 'Milliampere (mA)', id: 'mamp', fn: SIPrefix('A', -1) }, + { name: 'Volt (V)', id: 'volt', fn: SIPrefix('V') }, + { name: 'Kilovolt (kV)', id: 'kvolt', fn: SIPrefix('V', 1) }, + { name: 'Millivolt (mV)', id: 'mvolt', fn: SIPrefix('V', -1) }, + { name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: SIPrefix('dBm') }, + { name: 'Ohm (Ω)', id: 'ohm', fn: SIPrefix('Ω') }, + { name: 'Kiloohm (kΩ)', id: 'kohm', fn: SIPrefix('Ω', 1) }, + { name: 'Megaohm (MΩ)', id: 'Mohm', fn: SIPrefix('Ω', 2) }, + { name: 'Farad (F)', id: 'farad', fn: SIPrefix('F') }, + { name: 'Microfarad (µF)', id: 'µfarad', fn: SIPrefix('F', -2) }, + { name: 'Nanofarad (nF)', id: 'nfarad', fn: SIPrefix('F', -3) }, + { name: 'Picofarad (pF)', id: 'pfarad', fn: SIPrefix('F', -4) }, + { name: 'Femtofarad (fF)', id: 'ffarad', fn: SIPrefix('F', -5) }, + { name: 'Henry (H)', id: 'henry', fn: SIPrefix('H') }, + { name: 'Millihenry (mH)', id: 'mhenry', fn: SIPrefix('H', -1) }, + { name: 'Microhenry (µH)', id: 'µhenry', fn: SIPrefix('H', -2) }, + { name: 'Lumens (Lm)', id: 'lumens', fn: SIPrefix('Lm') }, + ], + }, + { + name: 'Flow', + formats: [ + { name: 'Gallons/min (gpm)', id: 'flowgpm', fn: toFixedUnit('gpm') }, + { name: 'Cubic meters/sec (cms)', id: 'flowcms', fn: toFixedUnit('cms') }, + { name: 'Cubic feet/sec (cfs)', id: 'flowcfs', fn: toFixedUnit('cfs') }, + { name: 'Cubic feet/min (cfm)', id: 'flowcfm', fn: toFixedUnit('cfm') }, + { name: 'Litre/hour', id: 'litreh', fn: toFixedUnit('L/h') }, + { name: 'Litre/min (L/min)', id: 'flowlpm', fn: toFixedUnit('L/min') }, + { name: 'milliLitre/min (mL/min)', id: 'flowmlpm', fn: toFixedUnit('mL/min') }, + { name: 'Lux (lx)', id: 'lux', fn: toFixedUnit('lux') }, + ], + }, + { + name: 'Force', + formats: [ + { name: 'Newton-meters (Nm)', id: 'forceNm', fn: SIPrefix('Nm') }, + { name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: SIPrefix('Nm', 1) }, + { name: 'Newtons (N)', id: 'forceN', fn: SIPrefix('N') }, + { name: 'Kilonewtons (kN)', id: 'forcekN', fn: SIPrefix('N', 1) }, + ], + }, + { + name: 'Hash rate', + formats: [ + { name: 'hashes/sec', id: 'Hs', fn: SIPrefix('H/s') }, + { name: 'kilohashes/sec', id: 'KHs', fn: SIPrefix('H/s', 1) }, + { name: 'megahashes/sec', id: 'MHs', fn: SIPrefix('H/s', 2) }, + { name: 'gigahashes/sec', id: 'GHs', fn: SIPrefix('H/s', 3) }, + { name: 'terahashes/sec', id: 'THs', fn: SIPrefix('H/s', 4) }, + { name: 'petahashes/sec', id: 'PHs', fn: SIPrefix('H/s', 5) }, + { name: 'exahashes/sec', id: 'EHs', fn: SIPrefix('H/s', 6) }, + ], + }, + { + name: 'Mass', + formats: [ + { name: 'milligram (mg)', id: 'massmg', fn: SIPrefix('g', -1) }, + { name: 'gram (g)', id: 'massg', fn: SIPrefix('g') }, + { name: 'pound (lb)', id: 'masslb', fn: toFixedUnit('lb') }, + { name: 'kilogram (kg)', id: 'masskg', fn: SIPrefix('g', 1) }, + { name: 'metric ton (t)', id: 'masst', fn: toFixedUnit('t') }, + ], + }, + { + name: 'Length', + formats: [ + { name: 'millimeter (mm)', id: 'lengthmm', fn: SIPrefix('m', -1) }, + { name: 'inch (in)', id: 'lengthin', fn: toFixedUnit('in') }, + { name: 'feet (ft)', id: 'lengthft', fn: toFixedUnit('ft') }, + { name: 'meter (m)', id: 'lengthm', fn: SIPrefix('m') }, + { name: 'kilometer (km)', id: 'lengthkm', fn: SIPrefix('m', 1) }, + { name: 'mile (mi)', id: 'lengthmi', fn: toFixedUnit('mi') }, + ], + }, + { + name: 'Pressure', + formats: [ + { name: 'Millibars', id: 'pressurembar', fn: SIPrefix('bar', -1) }, + { name: 'Bars', id: 'pressurebar', fn: SIPrefix('bar') }, + { name: 'Kilobars', id: 'pressurekbar', fn: SIPrefix('bar', 1) }, + { name: 'Pascals', id: 'pressurepa', fn: SIPrefix('Pa') }, + { name: 'Hectopascals', id: 'pressurehpa', fn: toFixedUnit('hPa') }, + { name: 'Kilopascals', id: 'pressurekpa', fn: toFixedUnit('kPa') }, + { name: 'Inches of mercury', id: 'pressurehg', fn: toFixedUnit('"Hg') }, + { name: 'PSI', id: 'pressurepsi', fn: scaledUnits(1000, ['psi', 'ksi', 'Mpsi']) }, + ], + }, + { + name: 'Radiation', + formats: [ + { name: 'Becquerel (Bq)', id: 'radbq', fn: SIPrefix('Bq') }, + { name: 'curie (Ci)', id: 'radci', fn: SIPrefix('Ci') }, + { name: 'Gray (Gy)', id: 'radgy', fn: SIPrefix('Gy') }, + { name: 'rad', id: 'radrad', fn: SIPrefix('rad') }, + { name: 'Sievert (Sv)', id: 'radsv', fn: SIPrefix('Sv') }, + { name: 'milliSievert (mSv)', id: 'radmsv', fn: SIPrefix('Sv', -1) }, + { name: 'microSievert (µSv)', id: 'radusv', fn: SIPrefix('Sv', -2) }, + { name: 'rem', id: 'radrem', fn: SIPrefix('rem') }, + { name: 'Exposure (C/kg)', id: 'radexpckg', fn: SIPrefix('C/kg') }, + { name: 'roentgen (R)', id: 'radr', fn: SIPrefix('R') }, + { name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: SIPrefix('Sv/h') }, + { name: 'milliSievert/hour (mSv/h)', id: 'radmsvh', fn: SIPrefix('Sv/h', -1) }, + { name: 'microSievert/hour (µSv/h)', id: 'radusvh', fn: SIPrefix('Sv/h', -2) }, + ], + }, + { + name: 'Rotational Speed', + formats: [ + { name: 'Revolutions per minute (rpm)', id: 'rotrpm', fn: toFixedUnit('rpm') }, + { name: 'Hertz (Hz)', id: 'rothz', fn: SIPrefix('Hz') }, + { name: 'Radians per second (rad/s)', id: 'rotrads', fn: toFixedUnit('rad/s') }, + { name: 'Degrees per second (°/s)', id: 'rotdegs', fn: toFixedUnit('°/s') }, + ], + }, + { + name: 'Temperature', + formats: [ + { name: 'Celsius (°C)', id: 'celsius', fn: toFixedUnit('°C') }, + { name: 'Fahrenheit (°F)', id: 'fahrenheit', fn: toFixedUnit('°F') }, + { name: 'Kelvin (K)', id: 'kelvin', fn: toFixedUnit('K') }, + ], + }, + { + name: 'Time', + formats: [ + { name: 'Hertz (1/s)', id: 'hertz', fn: SIPrefix('Hz') }, + { name: 'nanoseconds (ns)', id: 'ns', fn: toNanoSeconds }, + { name: 'microseconds (µs)', id: 'µs', fn: toMicroSeconds }, + { name: 'milliseconds (ms)', id: 'ms', fn: toMilliSeconds }, + { name: 'seconds (s)', id: 's', fn: toSeconds }, + { name: 'minutes (m)', id: 'm', fn: toMinutes }, + { name: 'hours (h)', id: 'h', fn: toHours }, + { name: 'days (d)', id: 'd', fn: toDays }, + { name: 'duration (ms)', id: 'dtdurationms', fn: toDurationInMilliseconds }, + { name: 'duration (s)', id: 'dtdurations', fn: toDurationInSeconds }, + { name: 'duration (hh:mm:ss)', id: 'dthms', fn: toDurationInHoursMinutesSeconds }, + { name: 'duration (d hh:mm:ss)', id: 'dtdhms', fn: toDurationInDaysHoursMinutesSeconds }, + { name: 'Timeticks (s/100)', id: 'timeticks', fn: toTimeTicks }, + { name: 'clock (ms)', id: 'clockms', fn: toClockMilliseconds }, + { name: 'clock (s)', id: 'clocks', fn: toClockSeconds }, + ], + }, + { + name: 'Throughput', + formats: [ + { name: 'counts/sec (cps)', id: 'cps', fn: simpleCountUnit('c/s') }, + { name: 'ops/sec (ops)', id: 'ops', fn: simpleCountUnit('ops/s') }, + { name: 'requests/sec (rps)', id: 'reqps', fn: simpleCountUnit('req/s') }, + { name: 'reads/sec (rps)', id: 'rps', fn: simpleCountUnit('rd/s') }, + { name: 'writes/sec (wps)', id: 'wps', fn: simpleCountUnit('wr/s') }, + { name: 'I/O ops/sec (iops)', id: 'iops', fn: simpleCountUnit('io/s') }, + { name: 'counts/min (cpm)', id: 'cpm', fn: simpleCountUnit('c/m') }, + { name: 'ops/min (opm)', id: 'opm', fn: simpleCountUnit('ops/m') }, + { name: 'reads/min (rpm)', id: 'rpm', fn: simpleCountUnit('rd/m') }, + { name: 'writes/min (wpm)', id: 'wpm', fn: simpleCountUnit('wr/m') }, + ], + }, + { + name: 'Velocity', + formats: [ + { name: 'meters/second (m/s)', id: 'velocityms', fn: toFixedUnit('m/s') }, + { name: 'kilometers/hour (km/h)', id: 'velocitykmh', fn: toFixedUnit('km/h') }, + { name: 'miles/hour (mph)', id: 'velocitymph', fn: toFixedUnit('mph') }, + { name: 'knot (kn)', id: 'velocityknot', fn: toFixedUnit('kn') }, + ], + }, + { + name: 'Volume', + formats: [ + { name: 'millilitre (mL)', id: 'mlitre', fn: SIPrefix('L', -1) }, + { name: 'litre (L)', id: 'litre', fn: SIPrefix('L') }, + { name: 'cubic meter', id: 'm3', fn: toFixedUnit('m³') }, + { name: 'Normal cubic meter', id: 'Nm3', fn: toFixedUnit('Nm³') }, + { name: 'cubic decimeter', id: 'dm3', fn: toFixedUnit('dm³') }, + { name: 'gallons', id: 'gallons', fn: toFixedUnit('gal') }, + ], + }, +]; diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts new file mode 100644 index 0000000..c40d7dd --- /dev/null +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts @@ -0,0 +1,374 @@ +import { + dateTimeAsIso, + dateTimeAsIsoNoDateIfToday, + dateTimeAsUS, + dateTimeAsUSNoDateIfToday, + getDateTimeAsLocalFormat, + getDateTimeAsLocalFormatNoDateIfToday, + dateTimeFromNow, + Interval, + toClock, + toDuration, + toDurationInMilliseconds, + toDurationInSeconds, + toDurationInHoursMinutesSeconds, + toDurationInDaysHoursMinutesSeconds, + toNanoSeconds, + toSeconds, +} from './dateTimeFormatters'; +import { formattedValueToString } from './valueFormats'; +import { toUtc, dateTime } from '../datetime/moment_wrapper'; + +describe('date time formats', () => { + const epoch = 1505634997920; + const utcTime = toUtc(epoch); + const browserTime = dateTime(epoch); + + it('should format as iso date', () => { + const expected = browserTime.format('YYYY-MM-DD HH:mm:ss'); + const actual = dateTimeAsIso(epoch, 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as iso date (in UTC)', () => { + const expected = utcTime.format('YYYY-MM-DD HH:mm:ss'); + const actual = dateTimeAsIso(epoch, 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as iso date and skip date when today', () => { + const now = dateTime(); + const expected = now.format('HH:mm:ss'); + const actual = dateTimeAsIsoNoDateIfToday(now.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as iso date (in UTC) and skip date when today', () => { + const now = toUtc(); + const expected = now.format('HH:mm:ss'); + const actual = dateTimeAsIsoNoDateIfToday(now.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as US date', () => { + const expected = browserTime.format('MM/DD/YYYY h:mm:ss a'); + const actual = dateTimeAsUS(epoch, 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as US date (in UTC)', () => { + const expected = utcTime.format('MM/DD/YYYY h:mm:ss a'); + const actual = dateTimeAsUS(epoch, 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as US date and skip date when today', () => { + const now = dateTime(); + const expected = now.format('h:mm:ss a'); + const actual = dateTimeAsUSNoDateIfToday(now.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as US date (in UTC) and skip date when today', () => { + const now = toUtc(); + const expected = now.format('h:mm:ss a'); + const actual = dateTimeAsUSNoDateIfToday(now.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as local date', () => { + const dateTimeObject = browserTime.toDate(); + const formattedDateText = getDateTimeAsLocalFormat()(epoch, 0, 0).text; + expect(formattedDateText).toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date and skip date when today', () => { + const now = dateTime(); + const dateTimeObject = now.toDate(); + const formattedDateText = getDateTimeAsLocalFormatNoDateIfToday()(now.valueOf(), 0, 0).text; + expect(formattedDateText).not.toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date (in UTC)', () => { + const dateTimeObject = utcTime.toDate(); + const formattedDateText = getDateTimeAsLocalFormat()(epoch, 0, 0, 'utc').text; + expect(formattedDateText).toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date (in UTC) and skip date when today', () => { + const now = toUtc(); + const dateTimeObject = now.toDate(); + const formattedDateText = getDateTimeAsLocalFormatNoDateIfToday()(now.valueOf(), 0, 0, 'utc').text; + expect(formattedDateText).not.toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as from now with days', () => { + const daysAgo = dateTime().add(-7, 'd'); + const expected = '7 days ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as from now with days (in UTC)', () => { + const daysAgo = toUtc().add(-7, 'd'); + const expected = '7 days ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as from now with minutes', () => { + const daysAgo = dateTime().add(-2, 'm'); + const expected = '2 minutes ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as from now with minutes (in UTC)', () => { + const daysAgo = toUtc().add(-2, 'm'); + const expected = '2 minutes ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); +}); + +describe('duration', () => { + it('0 milliseconds', () => { + const str = toDurationInMilliseconds(0, 0); + expect(formattedValueToString(str)).toBe('0 milliseconds'); + }); + it('1 millisecond', () => { + const str = toDurationInMilliseconds(1, 0); + expect(formattedValueToString(str)).toBe('1 millisecond'); + }); + it('-1 millisecond', () => { + const str = toDurationInMilliseconds(-1, 0); + expect(formattedValueToString(str)).toBe('1 millisecond ago'); + }); + it('seconds', () => { + const str = toDurationInSeconds(1, 0); + expect(formattedValueToString(str)).toBe('1 second'); + }); + it('minutes', () => { + const str = toDuration(1, 0, Interval.Minute); + expect(formattedValueToString(str)).toBe('1 minute'); + }); + it('hours', () => { + const str = toDuration(1, 0, Interval.Hour); + expect(formattedValueToString(str)).toBe('1 hour'); + }); + it('days', () => { + const str = toDuration(1, 0, Interval.Day); + expect(formattedValueToString(str)).toBe('1 day'); + }); + it('weeks', () => { + const str = toDuration(1, 0, Interval.Week); + expect(formattedValueToString(str)).toBe('1 week'); + }); + it('months', () => { + const str = toDuration(1, 0, Interval.Month); + expect(formattedValueToString(str)).toBe('1 month'); + }); + it('years', () => { + const str = toDuration(1, 0, Interval.Year); + expect(formattedValueToString(str)).toBe('1 year'); + }); + it('decimal days', () => { + const str = toDuration(1.5, 2, Interval.Day); + expect(formattedValueToString(str)).toBe('1 day, 12 hours, 0 minutes'); + }); + it('decimal months', () => { + const str = toDuration(1.5, 3, Interval.Month); + expect(formattedValueToString(str)).toBe('1 month, 2 weeks, 1 day, 0 hours'); + }); + it('no decimals', () => { + const str = toDuration(38898367008, 0, Interval.Millisecond); + expect(formattedValueToString(str)).toBe('1 year'); + }); + it('1 decimal', () => { + const str = toDuration(38898367008, 1, Interval.Millisecond); + expect(formattedValueToString(str)).toBe('1 year, 2 months'); + }); + it('too many decimals', () => { + const str = toDuration(38898367008, 20, Interval.Millisecond); + expect(formattedValueToString(str)).toBe( + '1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds' + ); + }); + it('floating point error', () => { + const str = toDuration(36993906007, 8, Interval.Millisecond); + expect(formattedValueToString(str)).toBe( + '1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds' + ); + }); + it('1 dthms', () => { + const str = toDurationInHoursMinutesSeconds(1); + expect(formattedValueToString(str)).toBe('00:00:01'); + }); + it('-1 dthms', () => { + const str = toDurationInHoursMinutesSeconds(-1); + expect(formattedValueToString(str)).toBe('00:00:01 ago'); + }); + it('0 dthms', () => { + const str = toDurationInHoursMinutesSeconds(0); + expect(formattedValueToString(str)).toBe('00:00:00'); + }); + it('1 dtdhms', () => { + const str = toDurationInHoursMinutesSeconds(1); + expect(formattedValueToString(str)).toBe('00:00:01'); + }); + it('-1 dtdhms', () => { + const str = toDurationInHoursMinutesSeconds(-1); + expect(formattedValueToString(str)).toBe('00:00:01 ago'); + }); + it('0 dtdhms', () => { + const str = toDurationInHoursMinutesSeconds(0); + expect(formattedValueToString(str)).toBe('00:00:00'); + }); + it('86399 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(86399); + expect(formattedValueToString(str)).toBe('23:59:59'); + }); + it('86400 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(86400); + expect(formattedValueToString(str)).toBe('1 d 00:00:00'); + }); + it('360000 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(360000); + expect(formattedValueToString(str)).toBe('4 d 04:00:00'); + }); + it('1179811 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(1179811); + expect(formattedValueToString(str)).toBe('13 d 15:43:31'); + }); + it('-1179811 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(-1179811); + expect(formattedValueToString(str)).toBe('13 d 15:43:31 ago'); + }); + it('116876364 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(116876364); + expect(formattedValueToString(str)).toBe('1352 d 17:39:24'); + }); +}); + +describe('clock', () => { + it('size less than 1 second', () => { + const str = toClock(999, 0); + expect(formattedValueToString(str)).toBe('999ms'); + }); + describe('size less than 1 minute', () => { + it('default', () => { + const str = toClock(59999); + expect(formattedValueToString(str)).toBe('59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(59999, 0); + expect(formattedValueToString(str)).toBe('59s'); + }); + }); + describe('size less than 1 hour', () => { + it('default', () => { + const str = toClock(3599999); + expect(formattedValueToString(str)).toBe('59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(3599999, 0); + expect(formattedValueToString(str)).toBe('59m'); + }); + it('decimals equals 1', () => { + const str = toClock(3599999, 1); + expect(formattedValueToString(str)).toBe('59m:59s'); + }); + }); + describe('size greater than or equal 1 hour', () => { + it('default', () => { + const str = toClock(7199999); + expect(formattedValueToString(str)).toBe('01h:59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(7199999, 0); + expect(formattedValueToString(str)).toBe('01h'); + }); + it('decimals equals 1', () => { + const str = toClock(7199999, 1); + expect(formattedValueToString(str)).toBe('01h:59m'); + }); + it('decimals equals 2', () => { + const str = toClock(7199999, 2); + expect(formattedValueToString(str)).toBe('01h:59m:59s'); + }); + }); + describe('size greater than or equal 1 day', () => { + it('default', () => { + const str = toClock(89999999); + expect(formattedValueToString(str)).toBe('24h:59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(89999999, 0); + expect(formattedValueToString(str)).toBe('24h'); + }); + it('decimals equals 1', () => { + const str = toClock(89999999, 1); + expect(formattedValueToString(str)).toBe('24h:59m'); + }); + it('decimals equals 2', () => { + const str = toClock(89999999, 2); + expect(formattedValueToString(str)).toBe('24h:59m:59s'); + }); + }); +}); + +describe('to nanoseconds', () => { + it('should correctly display as ns', () => { + const tenNanoseconds = toNanoSeconds(10); + expect(tenNanoseconds.text).toBe('10'); + expect(tenNanoseconds.suffix).toBe(' ns'); + }); + + it('should correctly display as µs', () => { + const threeMicroseconds = toNanoSeconds(3000); + expect(threeMicroseconds.text).toBe('3'); + expect(threeMicroseconds.suffix).toBe(' µs'); + }); + + it('should correctly display as ms', () => { + const fourMilliseconds = toNanoSeconds(4000000); + expect(fourMilliseconds.text).toBe('4'); + expect(fourMilliseconds.suffix).toBe(' ms'); + }); + + it('should correctly display as s', () => { + const fiveSeconds = toNanoSeconds(5000000000); + expect(fiveSeconds.text).toBe('5'); + expect(fiveSeconds.suffix).toBe(' s'); + }); + + it('should correctly display as minutes', () => { + const eightMinutes = toNanoSeconds(480000000000); + expect(eightMinutes.text).toBe('8'); + expect(eightMinutes.suffix).toBe(' min'); + }); + + it('should correctly display as hours', () => { + const nineHours = toNanoSeconds(32400000000000); + expect(nineHours.text).toBe('9'); + expect(nineHours.suffix).toBe(' hour'); + }); + + it('should correctly display as days', () => { + const tenDays = toNanoSeconds(864000000000000); + expect(tenDays.text).toBe('10'); + expect(tenDays.suffix).toBe(' day'); + }); +}); + +describe('seconds', () => { + it('should show 0 as 0', () => { + const zeroSeconds = toSeconds(0); + expect(zeroSeconds.text).toBe('0'); + expect(zeroSeconds.suffix).toBe(' s'); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts new file mode 100644 index 0000000..93f9a81 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts @@ -0,0 +1,420 @@ +import { toDuration as duration, toUtc, dateTime } from '../datetime/moment_wrapper'; + +import { toFixed, toFixedScaled, FormattedValue, ValueFormatter } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; +import { TimeZone } from '../types'; +import { dateTimeFormat, dateTimeFormatTimeAgo, localTimeFormat, systemDateFormats } from '../datetime'; + +interface IntervalsInSeconds { + [interval: string]: number; +} + +export enum Interval { + Year = 'year', + Month = 'month', + Week = 'week', + Day = 'day', + Hour = 'hour', + Minute = 'minute', + Second = 'second', + Millisecond = 'millisecond', +} + +const INTERVALS_IN_SECONDS: IntervalsInSeconds = { + [Interval.Year]: 31536000, + [Interval.Month]: 2592000, + [Interval.Week]: 604800, + [Interval.Day]: 86400, + [Interval.Hour]: 3600, + [Interval.Minute]: 60, + [Interval.Second]: 1, + [Interval.Millisecond]: 0.001, +}; + +export function toNanoSeconds(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 1000) { + return { text: toFixed(size, decimals), suffix: ' ns' }; + } else if (Math.abs(size) < 1000000) { + return toFixedScaled(size / 1000, decimals, ' µs'); + } else if (Math.abs(size) < 1000000000) { + return toFixedScaled(size / 1000000, decimals, ' ms'); + } else if (Math.abs(size) < 60000000000) { + return toFixedScaled(size / 1000000000, decimals, ' s'); + } else if (Math.abs(size) < 3600000000000) { + return toFixedScaled(size / 60000000000, decimals, ' min'); + } else if (Math.abs(size) < 86400000000000) { + return toFixedScaled(size / 3600000000000, decimals, ' hour'); + } else { + return toFixedScaled(size / 86400000000000, decimals, ' day'); + } +} + +export function toMicroSeconds(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 1000) { + return { text: toFixed(size, decimals), suffix: ' µs' }; + } else if (Math.abs(size) < 1000000) { + return toFixedScaled(size / 1000, decimals, ' ms'); + } else { + return toFixedScaled(size / 1000000, decimals, ' s'); + } +} + +export function toMilliSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 1000) { + return { text: toFixed(size, decimals), suffix: ' ms' }; + } else if (Math.abs(size) < 60000) { + // Less than 1 min + return toFixedScaled(size / 1000, decimals, ' s'); + } else if (Math.abs(size) < 3600000) { + // Less than 1 hour, divide in minutes + return toFixedScaled(size / 60000, decimals, ' min'); + } else if (Math.abs(size) < 86400000) { + // Less than one day, divide in hours + return toFixedScaled(size / 3600000, decimals, ' hour'); + } else if (Math.abs(size) < 31536000000) { + // Less than one year, divide in days + return toFixedScaled(size / 86400000, decimals, ' day'); + } + + return toFixedScaled(size / 31536000000, decimals, ' year'); +} + +export function trySubstract(value1: DecimalCount, value2: DecimalCount): DecimalCount { + if (value1 !== null && value1 !== undefined && value2 !== null && value2 !== undefined) { + return value1 - value2; + } + return undefined; +} + +export function toSeconds(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + // If 0, use s unit instead of ns + if (size === 0) { + return { text: '0', suffix: ' s' }; + } + + // Less than 1 µs, divide in ns + if (Math.abs(size) < 0.000001) { + return toFixedScaled(size * 1e9, decimals, ' ns'); + } + // Less than 1 ms, divide in µs + if (Math.abs(size) < 0.001) { + return toFixedScaled(size * 1e6, decimals, ' µs'); + } + // Less than 1 second, divide in ms + if (Math.abs(size) < 1) { + return toFixedScaled(size * 1e3, decimals, ' ms'); + } + + if (Math.abs(size) < 60) { + return { text: toFixed(size, decimals), suffix: ' s' }; + } else if (Math.abs(size) < 3600) { + // Less than 1 hour, divide in minutes + return toFixedScaled(size / 60, decimals, ' min'); + } else if (Math.abs(size) < 86400) { + // Less than one day, divide in hours + return toFixedScaled(size / 3600, decimals, ' hour'); + } else if (Math.abs(size) < 604800) { + // Less than one week, divide in days + return toFixedScaled(size / 86400, decimals, ' day'); + } else if (Math.abs(size) < 31536000) { + // Less than one year, divide in week + return toFixedScaled(size / 604800, decimals, ' week'); + } + + return toFixedScaled(size / 3.15569e7, decimals, ' year'); +} + +export function toMinutes(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 60) { + return { text: toFixed(size, decimals), suffix: ' min' }; + } else if (Math.abs(size) < 1440) { + return toFixedScaled(size / 60, decimals, ' hour'); + } else if (Math.abs(size) < 10080) { + return toFixedScaled(size / 1440, decimals, ' day'); + } else if (Math.abs(size) < 604800) { + return toFixedScaled(size / 10080, decimals, ' week'); + } else { + return toFixedScaled(size / 5.25948e5, decimals, ' year'); + } +} + +export function toHours(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 24) { + return { text: toFixed(size, decimals), suffix: ' hour' }; + } else if (Math.abs(size) < 168) { + return toFixedScaled(size / 24, decimals, ' day'); + } else if (Math.abs(size) < 8760) { + return toFixedScaled(size / 168, decimals, ' week'); + } else { + return toFixedScaled(size / 8760, decimals, ' year'); + } +} + +export function toDays(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 7) { + return { text: toFixed(size, decimals), suffix: ' day' }; + } else if (Math.abs(size) < 365) { + return toFixedScaled(size / 7, decimals, ' week'); + } else { + return toFixedScaled(size / 365, decimals, ' year'); + } +} + +export function toDuration(size: number, decimals: DecimalCount, timeScale: Interval): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (size === 0) { + return { text: '0', suffix: ' ' + timeScale + 's' }; + } + + if (size < 0) { + const v = toDuration(-size, decimals, timeScale); + if (!v.suffix) { + v.suffix = ''; + } + v.suffix += ' ago'; + return v; + } + + const units = [ + { long: Interval.Year }, + { long: Interval.Month }, + { long: Interval.Week }, + { long: Interval.Day }, + { long: Interval.Hour }, + { long: Interval.Minute }, + { long: Interval.Second }, + { long: Interval.Millisecond }, + ]; + + // convert $size to milliseconds + // intervals_in_seconds uses seconds (duh), convert them to milliseconds here to minimize floating point errors + size *= INTERVALS_IN_SECONDS[timeScale] * 1000; + + const strings = []; + + // after first value >= 1 print only $decimals more + let decrementDecimals = false; + let decimalsCount = 0; + + if (decimals !== null && decimals !== undefined) { + decimalsCount = decimals as number; + } + + for (let i = 0; i < units.length && decimalsCount >= 0; i++) { + const interval = INTERVALS_IN_SECONDS[units[i].long] * 1000; + const value = size / interval; + if (value >= 1 || decrementDecimals) { + decrementDecimals = true; + const floor = Math.floor(value); + const unit = units[i].long + (floor !== 1 ? 's' : ''); + strings.push(floor + ' ' + unit); + size = size % interval; + decimalsCount--; + } + } + + return { text: strings.join(', ') }; +} + +export function toClock(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + // < 1 second + if (size < 1000) { + return { + text: toUtc(size).format('SSS\\m\\s'), + }; + } + + // < 1 minute + if (size < 60000) { + let format = 'ss\\s:SSS\\m\\s'; + if (decimals === 0) { + format = 'ss\\s'; + } + return { text: toUtc(size).format(format) }; + } + + // < 1 hour + if (size < 3600000) { + let format = 'mm\\m:ss\\s:SSS\\m\\s'; + if (decimals === 0) { + format = 'mm\\m'; + } else if (decimals === 1) { + format = 'mm\\m:ss\\s'; + } + return { text: toUtc(size).format(format) }; + } + + let format = 'mm\\m:ss\\s:SSS\\m\\s'; + + const hours = `${('0' + Math.floor(duration(size, 'milliseconds').asHours())).slice(-2)}h`; + + if (decimals === 0) { + format = ''; + } else if (decimals === 1) { + format = 'mm\\m'; + } else if (decimals === 2) { + format = 'mm\\m:ss\\s'; + } + + const text = format ? `${hours}:${toUtc(size).format(format)}` : hours; + return { text }; +} + +export function toDurationInMilliseconds(size: number, decimals: DecimalCount): FormattedValue { + return toDuration(size, decimals, Interval.Millisecond); +} + +export function toDurationInSeconds(size: number, decimals: DecimalCount): FormattedValue { + return toDuration(size, decimals, Interval.Second); +} + +export function toDurationInHoursMinutesSeconds(size: number): FormattedValue { + if (size < 0) { + const v = toDurationInHoursMinutesSeconds(-size); + if (!v.suffix) { + v.suffix = ''; + } + v.suffix += ' ago'; + return v; + } + const strings = []; + const numHours = Math.floor(size / 3600); + const numMinutes = Math.floor((size % 3600) / 60); + const numSeconds = Math.floor((size % 3600) % 60); + numHours > 9 ? strings.push('' + numHours) : strings.push('0' + numHours); + numMinutes > 9 ? strings.push('' + numMinutes) : strings.push('0' + numMinutes); + numSeconds > 9 ? strings.push('' + numSeconds) : strings.push('0' + numSeconds); + return { text: strings.join(':') }; +} + +export function toDurationInDaysHoursMinutesSeconds(size: number): FormattedValue { + if (size < 0) { + const v = toDurationInDaysHoursMinutesSeconds(-size); + if (!v.suffix) { + v.suffix = ''; + } + v.suffix += ' ago'; + return v; + } + let dayString = ''; + const numDays = Math.floor(size / (24 * 3600)); + if (numDays > 0) { + dayString = numDays + ' d '; + } + const hmsString = toDurationInHoursMinutesSeconds(size - numDays * 24 * 3600); + return { text: dayString + hmsString.text }; +} + +export function toTimeTicks(size: number, decimals: DecimalCount): FormattedValue { + return toSeconds(size / 100, decimals); +} + +export function toClockMilliseconds(size: number, decimals: DecimalCount): FormattedValue { + return toClock(size, decimals); +} + +export function toClockSeconds(size: number, decimals: DecimalCount): FormattedValue { + return toClock(size * 1000, decimals); +} + +export function toDateTimeValueFormatter(pattern: string, todayPattern?: string): ValueFormatter { + return (value: number, decimals: DecimalCount, scaledDecimals: DecimalCount, timeZone?: TimeZone): FormattedValue => { + if (todayPattern) { + if (dateTime().isSame(value, 'day')) { + return { + text: dateTimeFormat(value, { format: todayPattern, timeZone }), + }; + } + } + return { text: dateTimeFormat(value, { format: pattern, timeZone }) }; + }; +} + +export const dateTimeAsIso = toDateTimeValueFormatter('YYYY-MM-DD HH:mm:ss'); +export const dateTimeAsIsoNoDateIfToday = toDateTimeValueFormatter('YYYY-MM-DD HH:mm:ss', 'HH:mm:ss'); +export const dateTimeAsUS = toDateTimeValueFormatter('MM/DD/YYYY h:mm:ss a'); +export const dateTimeAsUSNoDateIfToday = toDateTimeValueFormatter('MM/DD/YYYY h:mm:ss a', 'h:mm:ss a'); + +export function getDateTimeAsLocalFormat() { + return toDateTimeValueFormatter( + localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ); +} + +export function getDateTimeAsLocalFormatNoDateIfToday() { + return toDateTimeValueFormatter( + localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + localTimeFormat({ + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ); +} + +export function dateTimeSystemFormatter( + value: number, + decimals: DecimalCount, + scaledDecimals: DecimalCount, + timeZone?: TimeZone +): FormattedValue { + return { text: dateTimeFormat(value, { format: systemDateFormats.fullDate, timeZone }) }; +} + +export function dateTimeFromNow( + value: number, + decimals: DecimalCount, + scaledDecimals: DecimalCount, + timeZone?: TimeZone +): FormattedValue { + return { text: dateTimeFormatTimeAgo(value, { timeZone }) }; +} diff --git a/packages/grafana-data/src/valueFormats/index.ts b/packages/grafana-data/src/valueFormats/index.ts new file mode 100644 index 0000000..c76a443 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/index.ts @@ -0,0 +1 @@ +export * from './valueFormats'; diff --git a/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts b/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts new file mode 100644 index 0000000..63f0e46 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts @@ -0,0 +1,55 @@ +import { currency } from './symbolFormatters'; + +describe('currency', () => { + const symbol = '@'; + + describe('when called without asSuffix', () => { + const fmtFunc = currency(symbol); + + it.each` + value | expectedSuffix | expectedText + ${999} | ${''} | ${'999'} + ${1000} | ${'K'} | ${'1'} + ${1000000} | ${'M'} | ${'1'} + ${1000000000} | ${'B'} | ${'1'} + ${1000000000000} | ${'T'} | ${'1'} + ${1000000000000000} | ${undefined} | ${'NA'} + ${-1000000000000} | ${'T'} | ${'-1'} + ${-1000000000} | ${'B'} | ${'-1'} + ${-1000000} | ${'M'} | ${'-1'} + ${-1000} | ${'K'} | ${'-1'} + ${-999} | ${''} | ${'-999'} + `('when called with value:{$value}', ({ value, expectedSuffix, expectedText }) => { + const { prefix, suffix, text } = fmtFunc(value); + + expect(prefix).toEqual(symbol); + expect(suffix).toEqual(expectedSuffix); + expect(text).toEqual(expectedText); + }); + }); + + describe('when called with asSuffix', () => { + const fmtFunc = currency(symbol, true); + + it.each` + value | expectedSuffix | expectedText + ${999} | ${'@'} | ${'999'} + ${1000} | ${'K@'} | ${'1'} + ${1000000} | ${'M@'} | ${'1'} + ${1000000000} | ${'B@'} | ${'1'} + ${1000000000000} | ${'T@'} | ${'1'} + ${1000000000000000} | ${undefined} | ${'NA'} + ${-1000000000000} | ${'T@'} | ${'-1'} + ${-1000000000} | ${'B@'} | ${'-1'} + ${-1000000} | ${'M@'} | ${'-1'} + ${-1000} | ${'K@'} | ${'-1'} + ${-999} | ${'@'} | ${'-999'} + `('when called with value:{$value}', ({ value, expectedSuffix, expectedText }) => { + const { prefix, suffix, text } = fmtFunc(value); + + expect(prefix).toEqual(undefined); + expect(suffix).toEqual(expectedSuffix); + expect(text).toEqual(expectedText); + }); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/symbolFormatters.ts b/packages/grafana-data/src/valueFormats/symbolFormatters.ts new file mode 100644 index 0000000..54c96bf --- /dev/null +++ b/packages/grafana-data/src/valueFormats/symbolFormatters.ts @@ -0,0 +1,71 @@ +import { scaledUnits, ValueFormatter } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; + +export function currency(symbol: string, asSuffix?: boolean): ValueFormatter { + const units = ['', 'K', 'M', 'B', 'T']; + const scaler = scaledUnits(1000, units); + return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + const scaled = scaler(size, decimals, scaledDecimals); + if (asSuffix) { + scaled.suffix = scaled.suffix !== undefined ? `${scaled.suffix}${symbol}` : undefined; + } else { + scaled.prefix = symbol; + } + return scaled; + }; +} + +export function getOffsetFromSIPrefix(c: string): number { + switch (c) { + case 'f': + return -5; + case 'p': + return -4; + case 'n': + return -3; + case 'μ': // Two different unicode chars for µ + case 'µ': + return -2; + case 'm': + return -1; + case '': + return 0; + case 'k': + return 1; + case 'M': + return 2; + case 'G': + return 3; + case 'T': + return 4; + case 'P': + return 5; + case 'E': + return 6; + case 'Z': + return 7; + case 'Y': + return 8; + } + return 0; +} + +export function binaryPrefix(unit: string, offset = 0): ValueFormatter { + const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'].slice(offset); + const units = prefixes.map((p) => { + return ' ' + p + unit; + }); + return scaledUnits(1024, units); +} + +export function SIPrefix(unit: string, offset = 0): ValueFormatter { + let prefixes = ['f', 'p', 'n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; + prefixes = prefixes.slice(5 + (offset || 0)); + const units = prefixes.map((p) => { + return ' ' + p + unit; + }); + return scaledUnits(1000, units); +} diff --git a/packages/grafana-data/src/valueFormats/valueFormats.test.ts b/packages/grafana-data/src/valueFormats/valueFormats.test.ts new file mode 100644 index 0000000..3aedf0f --- /dev/null +++ b/packages/grafana-data/src/valueFormats/valueFormats.test.ts @@ -0,0 +1,140 @@ +import { toFixed, getValueFormat, scaledUnits, formattedValueToString } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; +import { TimeZone } from '../types'; +import { dateTime } from '../datetime'; + +interface ValueFormatTest { + id: string; + decimals?: DecimalCount; + scaledDecimals?: DecimalCount; + timeZone?: TimeZone; + value: number; + result: string; +} + +describe('valueFormats', () => { + it.each` + format | decimals | value | expected + ${'currencyUSD'} | ${2} | ${1532.82} | ${'$1.53K'} + ${'currencyKRW'} | ${2} | ${1532.82} | ${'₩1.53K'} + ${'currencyIDR'} | ${2} | ${1532.82} | ${'Rp1.53K'} + ${'none'} | ${undefined} | ${3.23} | ${'3.23'} + ${'none'} | ${undefined} | ${0.0245} | ${'0.0245'} + ${'none'} | ${undefined} | ${1 / 3} | ${'0.333'} + ${'ms'} | ${4} | ${0.0024} | ${'0.0024 ms'} + ${'ms'} | ${0} | ${100} | ${'100 ms'} + ${'ms'} | ${2} | ${1250} | ${'1.25 s'} + ${'ms'} | ${1} | ${10000086.123} | ${'2.8 hour'} + ${'ms'} | ${undefined} | ${1000} | ${'1 s'} + ${'ms'} | ${0} | ${1200} | ${'1 s'} + ${'short'} | ${undefined} | ${1000} | ${'1 K'} + ${'short'} | ${undefined} | ${1200} | ${'1.20 K'} + ${'short'} | ${undefined} | ${1250} | ${'1.25 K'} + ${'short'} | ${undefined} | ${1000000} | ${'1 Mil'} + ${'short'} | ${undefined} | ${1500000} | ${'1.50 Mil'} + ${'short'} | ${undefined} | ${1000120} | ${'1.00 Mil'} + ${'short'} | ${undefined} | ${98765} | ${'98.8 K'} + ${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'} + ${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'} + ${'kbytes'} | ${undefined} | ${10000000} | ${'9.54 GiB'} + ${'deckbytes'} | ${undefined} | ${10000000} | ${'10 GB'} + ${'megwatt'} | ${3} | ${1000} | ${'1.000 GW'} + ${'kohm'} | ${3} | ${1000} | ${'1.000 MΩ'} + ${'Mohm'} | ${3} | ${1000} | ${'1.000 GΩ'} + ${'farad'} | ${3} | ${1000} | ${'1.000 kF'} + ${'µfarad'} | ${3} | ${1000} | ${'1.000 mF'} + ${'nfarad'} | ${3} | ${1000} | ${'1.000 µF'} + ${'pfarad'} | ${3} | ${1000} | ${'1.000 nF'} + ${'ffarad'} | ${3} | ${1000} | ${'1.000 pF'} + ${'henry'} | ${3} | ${1000} | ${'1.000 kH'} + ${'mhenry'} | ${3} | ${1000} | ${'1.000 H'} + ${'µhenry'} | ${3} | ${1000} | ${'1.000 mH'} + ${'a'} | ${0} | ${1532.82} | ${'1533 a'} + ${'b'} | ${0} | ${1532.82} | ${'1533 b'} + ${'prefix:b'} | ${undefined} | ${1532.82} | ${'b1533'} + ${'suffix:d'} | ${undefined} | ${1532.82} | ${'1533 d'} + ${'si:µF'} | ${2} | ${1234} | ${'1.23 mF'} + ${'si:µF'} | ${2} | ${1234000000} | ${'1.23 kF'} + ${'si:µF'} | ${2} | ${1234000000000000} | ${'1.23 GF'} + ${'count:xpm'} | ${2} | ${1234567} | ${'1.23M xpm'} + ${'count:x/min'} | ${2} | ${1234} | ${'1.23K x/min'} + ${'currency:@'} | ${2} | ${1234567} | ${'@1.23M'} + ${'currency:@'} | ${2} | ${1234} | ${'@1.23K'} + ${'time:YYYY'} | ${0} | ${dateTime(new Date(1999, 6, 2)).valueOf()} | ${'1999'} + ${'time:YYYY.MM'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010.07'} + ${'dateTimeAsIso'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'} + ${'dateTimeAsUS'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'07/02/2010 12:00:00 am'} + ${'dateTimeAsSystem'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'} + ${'dtdurationms'} | ${undefined} | ${100000} | ${'1 minute'} + `( + 'With format=$format decimals=$decimals and value=$value then result shoudl be = $expected', + async ({ format, value, decimals, expected }) => { + const result = getValueFormat(format)(value, decimals, undefined, undefined); + const full = formattedValueToString(result); + expect(full).toBe(expected); + } + ); + + it('Manually check a format', () => { + // helpful for adding tests one at a time with the debugger + const tests: ValueFormatTest[] = [ + { id: 'time:YYYY.MM', decimals: 0, value: dateTime(new Date(2010, 6, 2)).valueOf(), result: '2010.07' }, + ]; + const test = tests[0]; + const result = getValueFormat(test.id)(test.value, test.decimals, test.scaledDecimals); + const full = formattedValueToString(result); + expect(full).toBe(test.result); + }); + + describe('normal cases', () => { + it('toFixed should handle number correctly if decimal is null', () => { + expect(toFixed(100)).toBe('100'); + + expect(toFixed(100.4)).toBe('100'); + expect(toFixed(100.5)).toBe('101'); + }); + + it('toFixed should handle number correctly if decimal is not null', () => { + expect(toFixed(100, 1)).toBe('100.0'); + + expect(toFixed(100.37, 1)).toBe('100.4'); + expect(toFixed(100.63, 1)).toBe('100.6'); + + expect(toFixed(100.4, 2)).toBe('100.40'); + expect(toFixed(100.5, 2)).toBe('100.50'); + }); + }); + + describe('format edge cases', () => { + const negInf = Number.NEGATIVE_INFINITY.toLocaleString(); + const posInf = Number.POSITIVE_INFINITY.toLocaleString(); + + it('toFixed should handle non number input gracefully', () => { + expect(toFixed(NaN)).toBe('NaN'); + expect(toFixed(Number.NEGATIVE_INFINITY)).toBe(negInf); + expect(toFixed(Number.POSITIVE_INFINITY)).toBe(posInf); + }); + + it('scaledUnits should handle non number input gracefully', () => { + const disp = scaledUnits(5, ['a', 'b', 'c']); + expect(disp(NaN).text).toBe('NaN'); + expect(disp(Number.NEGATIVE_INFINITY).text).toBe(negInf); + expect(disp(Number.POSITIVE_INFINITY).text).toBe(posInf); + }); + }); + + describe('toFixed and negative decimals', () => { + it('should treat as zero decimals', () => { + const str = toFixed(186.123, -2); + expect(str).toBe('186'); + }); + }); + + describe('Resolve old units', () => { + it('resolve farenheit', () => { + const fmt0 = getValueFormat('farenheit'); + const fmt1 = getValueFormat('fahrenheit'); + expect(fmt0).toEqual(fmt1); + }); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/valueFormats.ts b/packages/grafana-data/src/valueFormats/valueFormats.ts new file mode 100644 index 0000000..3d2174f --- /dev/null +++ b/packages/grafana-data/src/valueFormats/valueFormats.ts @@ -0,0 +1,265 @@ +import { getCategories } from './categories'; +import { DecimalCount } from '../types/displayValue'; +import { toDateTimeValueFormatter } from './dateTimeFormatters'; +import { getOffsetFromSIPrefix, SIPrefix, currency } from './symbolFormatters'; +import { TimeZone } from '../types'; + +export interface FormattedValue { + text: string; + prefix?: string; + suffix?: string; +} + +export function formattedValueToString(val: FormattedValue): string { + return `${val.prefix ?? ''}${val.text}${val.suffix ?? ''}`; +} + +export type ValueFormatter = ( + value: number, + decimals?: DecimalCount, + scaledDecimals?: DecimalCount, + timeZone?: TimeZone +) => FormattedValue; + +export interface ValueFormat { + name: string; + id: string; + fn: ValueFormatter; +} + +export interface ValueFormatCategory { + name: string; + formats: ValueFormat[]; +} + +export interface ValueFormatterIndex { + [id: string]: ValueFormatter; +} + +// Globals & formats cache +let categories: ValueFormatCategory[] = []; +const index: ValueFormatterIndex = {}; +let hasBuiltIndex = false; + +export function toFixed(value: number, decimals?: DecimalCount): string { + if (value === null) { + return ''; + } + + if (value === Number.NEGATIVE_INFINITY || value === Number.POSITIVE_INFINITY) { + return value.toLocaleString(); + } + + if (decimals === null || decimals === undefined) { + decimals = getDecimalsForValue(value); + } + + const factor = decimals ? Math.pow(10, Math.max(0, decimals)) : 1; + const formatted = String(Math.round(value * factor) / factor); + + // if exponent return directly + if (formatted.indexOf('e') !== -1 || value === 0) { + return formatted; + } + + const decimalPos = formatted.indexOf('.'); + const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1; + if (precision < decimals) { + return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision); + } + + return formatted; +} + +function getDecimalsForValue(value: number): number { + const log10 = Math.floor(Math.log(Math.abs(value)) / Math.LN10); + let dec = -log10 + 1; + const magn = Math.pow(10, -dec); + const norm = value / magn; // norm is between 1.0 and 10.0 + + // special case for 2.5, requires an extra decimal + if (norm > 2.25) { + ++dec; + } + + if (value % 1 === 0) { + dec = 0; + } + + const decimals = Math.max(0, dec); + return decimals; +} + +export function toFixedScaled(value: number, decimals: DecimalCount, ext?: string): FormattedValue { + return { + text: toFixed(value, decimals), + suffix: ext, + }; +} + +export function toFixedUnit(unit: string, asPrefix?: boolean): ValueFormatter { + return (size: number, decimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + const text = toFixed(size, decimals); + if (unit) { + if (asPrefix) { + return { text, prefix: unit }; + } + return { text, suffix: ' ' + unit }; + } + return { text }; + }; +} + +// Formatter which scales the unit string geometrically according to the given +// numeric factor. Repeatedly scales the value down by the factor until it is +// less than the factor in magnitude, or the end of the array is reached. +export function scaledUnits(factor: number, extArray: string[]): ValueFormatter { + return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + if (size === Number.NEGATIVE_INFINITY || size === Number.POSITIVE_INFINITY || isNaN(size)) { + return { text: size.toLocaleString() }; + } + + let steps = 0; + const limit = extArray.length; + + while (Math.abs(size) >= factor) { + steps++; + size /= factor; + + if (steps >= limit) { + return { text: 'NA' }; + } + } + + return { text: toFixed(size, decimals), suffix: extArray[steps] }; + }; +} + +export function locale(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + return { + text: value.toLocaleString(undefined, { maximumFractionDigits: decimals as number }), + }; +} + +export function simpleCountUnit(symbol: string): ValueFormatter { + const units = ['', 'K', 'M', 'B', 'T']; + const scaler = scaledUnits(1000, units); + return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + const v = scaler(size, decimals, scaledDecimals); + v.suffix += ' ' + symbol; + return v; + }; +} + +export function stringFormater(value: number): FormattedValue { + return { text: `${value}` }; +} + +function buildFormats() { + categories = getCategories(); + + for (const cat of categories) { + for (const format of cat.formats) { + index[format.id] = format.fn; + } + } + + // Resolve units pointing to old IDs + [{ from: 'farenheit', to: 'fahrenheit' }].forEach((alias) => { + const f = index[alias.to]; + if (f) { + index[alias.from] = f; + } + }); + + hasBuiltIndex = true; +} + +export function getValueFormat(id?: string | null): ValueFormatter { + if (!id) { + return toFixedUnit(''); + } + + if (!hasBuiltIndex) { + buildFormats(); + } + + const fmt = index[id]; + + if (!fmt && id) { + const idx = id.indexOf(':'); + + if (idx > 0) { + const key = id.substring(0, idx); + const sub = id.substring(idx + 1); + + if (key === 'prefix') { + return toFixedUnit(sub, true); + } + + if (key === 'suffix') { + return toFixedUnit(sub, false); + } + + if (key === 'time') { + return toDateTimeValueFormatter(sub); + } + + if (key === 'si') { + const offset = getOffsetFromSIPrefix(sub.charAt(0)); + const unit = offset === 0 ? sub : sub.substring(1); + return SIPrefix(unit, offset); + } + + if (key === 'count') { + return simpleCountUnit(sub); + } + + if (key === 'currency') { + return currency(sub); + } + } + + return toFixedUnit(id); + } + + return fmt; +} + +export function getValueFormatterIndex(): ValueFormatterIndex { + if (!hasBuiltIndex) { + buildFormats(); + } + + return index; +} + +export function getValueFormats() { + if (!hasBuiltIndex) { + buildFormats(); + } + + return categories.map((cat) => { + return { + text: cat.name, + submenu: cat.formats.map((format) => { + return { + text: format.name, + value: format.id, + }; + }), + }; + }); +} diff --git a/packages/grafana-data/src/vector/AppendedVectors.test.ts b/packages/grafana-data/src/vector/AppendedVectors.test.ts new file mode 100644 index 0000000..9d39b47 --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.test.ts @@ -0,0 +1,23 @@ +import { ArrayVector } from './ArrayVector'; +import { AppendedVectors } from './AppendedVectors'; + +describe('Check Appending Vector', () => { + it('should transparently join them', () => { + const appended = new AppendedVectors(); + appended.append(new ArrayVector([1, 2, 3])); + appended.append(new ArrayVector([4, 5, 6])); + appended.append(new ArrayVector([7, 8, 9])); + expect(appended.length).toEqual(9); + + appended.setLength(5); + expect(appended.length).toEqual(5); + appended.append(new ArrayVector(['a', 'b', 'c'])); + expect(appended.length).toEqual(8); + expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); + + appended.setLength(2); + appended.setLength(6); + appended.append(new ArrayVector(['x', 'y', 'z'])); + expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); + }); +}); diff --git a/packages/grafana-data/src/vector/AppendedVectors.ts b/packages/grafana-data/src/vector/AppendedVectors.ts new file mode 100644 index 0000000..a10568a --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.ts @@ -0,0 +1,73 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +interface AppendedVectorInfo { + start: number; + end: number; + values: Vector; +} + +/** + * This may be more trouble than it is worth. This trades some computation time for + * RAM -- rather than allocate a new array the size of all previous arrays, this just + * points the correct index to their original array values + */ +export class AppendedVectors implements Vector { + length = 0; + source: Array> = []; + + constructor(startAt = 0) { + this.length = startAt; + } + + /** + * Make the vector look like it is this long + */ + setLength(length: number) { + if (length > this.length) { + // make the vector longer (filling with undefined) + this.length = length; + } else if (length < this.length) { + // make the array shorter + const sources: Array> = []; + for (const src of this.source) { + sources.push(src); + if (src.end > length) { + src.end = length; + break; + } + } + this.source = sources; + this.length = length; + } + } + + append(v: Vector): AppendedVectorInfo { + const info = { + start: this.length, + end: this.length + v.length, + values: v, + }; + this.length = info.end; + this.source.push(info); + return info; + } + + get(index: number): T { + for (let i = 0; i < this.source.length; i++) { + const src = this.source[i]; + if (index >= src.start && index < src.end) { + return src.values.get(index - src.start); + } + } + return (undefined as unknown) as T; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ArrayVector.ts b/packages/grafana-data/src/vector/ArrayVector.ts new file mode 100644 index 0000000..3df650e --- /dev/null +++ b/packages/grafana-data/src/vector/ArrayVector.ts @@ -0,0 +1,42 @@ +import { MutableVector } from '../types/vector'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * @public + */ +export class ArrayVector extends FunctionalVector implements MutableVector { + buffer: T[]; + + constructor(buffer?: T[]) { + super(); + this.buffer = buffer ? buffer : []; + } + + get length() { + return this.buffer.length; + } + + add(value: T) { + this.buffer.push(value); + } + + get(index: number): T { + return this.buffer[index]; + } + + set(index: number, value: T) { + this.buffer[index] = value; + } + + reverse() { + this.buffer.reverse(); + } + + toArray(): T[] { + return this.buffer; + } + + toJSON(): T[] { + return this.buffer; + } +} diff --git a/packages/grafana-data/src/vector/AsNumberVector.ts b/packages/grafana-data/src/vector/AsNumberVector.ts new file mode 100644 index 0000000..c8a6591 --- /dev/null +++ b/packages/grafana-data/src/vector/AsNumberVector.ts @@ -0,0 +1,21 @@ +import { Vector } from '../types'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * This will force all values to be numbers + * + * @public + */ +export class AsNumberVector extends FunctionalVector { + constructor(private field: Vector) { + super(); + } + + get length() { + return this.field.length; + } + + get(index: number) { + return +this.field.get(index); + } +} diff --git a/packages/grafana-data/src/vector/BinaryOperationVector.test.ts b/packages/grafana-data/src/vector/BinaryOperationVector.test.ts new file mode 100644 index 0000000..c6d9955 --- /dev/null +++ b/packages/grafana-data/src/vector/BinaryOperationVector.test.ts @@ -0,0 +1,18 @@ +import { ArrayVector } from './ArrayVector'; +import { BinaryOperationVector } from './BinaryOperationVector'; +import { ConstantVector } from './ConstantVector'; +import { binaryOperators, BinaryOperationID } from '../utils/binaryOperators'; + +describe('ScaledVector', () => { + it('should support multiply operations', () => { + const source = new ArrayVector([1, 2, 3, 4]); + const scale = 2.456; + const operation = binaryOperators.get(BinaryOperationID.Multiply).operation; + const v = new BinaryOperationVector(source, new ConstantVector(scale, source.length), operation); + expect(v.length).toEqual(source.length); + // expect(v.push(10)).toEqual(source.length); // not implemented + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(source.get(i) * scale); + } + }); +}); diff --git a/packages/grafana-data/src/vector/BinaryOperationVector.ts b/packages/grafana-data/src/vector/BinaryOperationVector.ts new file mode 100644 index 0000000..1740f99 --- /dev/null +++ b/packages/grafana-data/src/vector/BinaryOperationVector.ts @@ -0,0 +1,26 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; +import { BinaryOperation } from '../utils/binaryOperators'; + +/** + * @public + */ +export class BinaryOperationVector implements Vector { + constructor(private left: Vector, private right: Vector, private operation: BinaryOperation) {} + + get length(): number { + return this.left.length; + } + + get(index: number): number { + return this.operation(this.left.get(index), this.right.get(index)); + } + + toArray(): number[] { + return vectorToArray(this); + } + + toJSON(): number[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/CircularVector.test.ts b/packages/grafana-data/src/vector/CircularVector.test.ts new file mode 100644 index 0000000..abffd15 --- /dev/null +++ b/packages/grafana-data/src/vector/CircularVector.test.ts @@ -0,0 +1,131 @@ +import { CircularVector } from './CircularVector'; + +describe('Check Circular Vector', () => { + it('should append values', () => { + const buffer = [1, 2, 3]; + const v = new CircularVector({ buffer }); // tail is default option + expect(v.toArray()).toEqual([1, 2, 3]); + + v.add(4); + expect(v.toArray()).toEqual([2, 3, 4]); + + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + + v.add(6); + expect(v.toArray()).toEqual([4, 5, 6]); + + v.add(7); + expect(v.toArray()).toEqual([5, 6, 7]); + + v.add(8); + expect(v.toArray()).toEqual([6, 7, 8]); + }); + + it('should grow buffer until it hits capacity (append)', () => { + const v = new CircularVector({ capacity: 3 }); // tail is default option + expect(v.toArray()).toEqual([]); + + v.add(1); + expect(v.toArray()).toEqual([1]); + + v.add(2); + expect(v.toArray()).toEqual([1, 2]); + + v.add(3); + expect(v.toArray()).toEqual([1, 2, 3]); + + v.add(4); + expect(v.toArray()).toEqual([2, 3, 4]); + + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + }); + + it('should prepend values', () => { + const buffer = [3, 2, 1]; + const v = new CircularVector({ buffer, append: 'head' }); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.add(5); + expect(v.toArray()).toEqual([5, 4, 3]); + + v.add(6); + expect(v.toArray()).toEqual([6, 5, 4]); + + v.add(7); + expect(v.toArray()).toEqual([7, 6, 5]); + + v.add(8); + expect(v.toArray()).toEqual([8, 7, 6]); + }); + + it('should expand buffer and then prepend', () => { + const v = new CircularVector({ capacity: 3, append: 'head' }); + expect(v.toArray()).toEqual([]); + + v.add(1); + expect(v.toArray()).toEqual([1]); + + v.add(2); + expect(v.toArray()).toEqual([2, 1]); + + v.add(3); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.add(5); + expect(v.toArray()).toEqual([5, 4, 3]); + }); + + it('should reduce size and keep working (tail)', () => { + const buffer = [1, 2, 3, 4, 5]; + const v = new CircularVector({ buffer }); + expect(v.toArray()).toEqual([1, 2, 3, 4, 5]); + + v.setCapacity(3); + expect(v.toArray()).toEqual([3, 4, 5]); + + v.add(6); + expect(v.toArray()).toEqual([4, 5, 6]); + + v.add(7); + expect(v.toArray()).toEqual([5, 6, 7]); + }); + + it('should reduce size and keep working (head)', () => { + const buffer = [5, 4, 3, 2, 1]; + const v = new CircularVector({ buffer, append: 'head' }); + expect(v.toArray()).toEqual([5, 4, 3, 2, 1]); + + v.setCapacity(3); + expect(v.toArray()).toEqual([5, 4, 3]); + + v.add(6); + expect(v.toArray()).toEqual([6, 5, 4]); + + v.add(7); + expect(v.toArray()).toEqual([7, 6, 5]); + }); + + it('change buffer direction', () => { + const buffer = [1, 2, 3]; + const v = new CircularVector({ buffer }); + expect(v.toArray()).toEqual([1, 2, 3]); + + v.setAppendMode('head'); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.setAppendMode('tail'); + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + }); +}); diff --git a/packages/grafana-data/src/vector/CircularVector.ts b/packages/grafana-data/src/vector/CircularVector.ts new file mode 100644 index 0000000..3f3b1f6 --- /dev/null +++ b/packages/grafana-data/src/vector/CircularVector.ts @@ -0,0 +1,143 @@ +import { MutableVector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; +import { FunctionalVector } from './FunctionalVector'; + +interface CircularOptions { + buffer?: T[]; + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * Circular vector uses a single buffer to capture a stream of values + * overwriting the oldest value on add. + * + * This supports adding to the 'head' or 'tail' and will grow the buffer + * to match a configured capacity. + * + * @public + */ +export class CircularVector extends FunctionalVector implements MutableVector { + private buffer: T[]; + private index: number; + private capacity: number; + private tail: boolean; + + constructor(options: CircularOptions) { + super(); + + this.buffer = options.buffer || []; + this.capacity = this.buffer.length; + this.tail = 'head' !== options.append; + this.index = 0; + + this.add = this.getAddFunction(); + if (options.capacity) { + this.setCapacity(options.capacity); + } + } + + /** + * This gets the appropriate add function depending on the buffer state: + * * head vs tail + * * growing buffer vs overwriting values + */ + private getAddFunction() { + // When we are not at capacity, it should actually modify the buffer + if (this.capacity > this.buffer.length) { + if (this.tail) { + return (value: T) => { + this.buffer.push(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } else { + return (value: T) => { + this.buffer.unshift(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } + } + + if (this.tail) { + return (value: T) => { + this.buffer[this.index] = value; + this.index = (this.index + 1) % this.buffer.length; + }; + } + + // Append values to the head + return (value: T) => { + let idx = this.index - 1; + if (idx < 0) { + idx = this.buffer.length - 1; + } + this.buffer[idx] = value; + this.index = idx; + }; + } + + setCapacity(v: number) { + if (this.capacity === v) { + return; + } + // Make a copy so it is in order and new additions can be at the head or tail + const copy = this.toArray(); + if (v > this.length) { + this.buffer = copy; + } else if (v < this.capacity) { + // Shrink the buffer + const delta = this.length - v; + if (this.tail) { + this.buffer = copy.slice(delta, copy.length); // Keep last items + } else { + this.buffer = copy.slice(0, copy.length - delta); // Keep first items + } + } + this.capacity = v; + this.index = 0; + this.add = this.getAddFunction(); + } + + setAppendMode(mode: 'head' | 'tail') { + const tail = 'head' !== mode; + if (tail !== this.tail) { + this.buffer = this.toArray().reverse(); + this.index = 0; + this.tail = tail; + this.add = this.getAddFunction(); + } + } + + reverse() { + this.buffer.reverse(); + } + + /** + * Add the value to the buffer + */ + add: (value: T) => void; + + get(index: number) { + return this.buffer[(index + this.index) % this.buffer.length]; + } + + set(index: number, value: T) { + this.buffer[(index + this.index) % this.buffer.length] = value; + } + + get length() { + return this.buffer.length; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ConstantVector.test.ts b/packages/grafana-data/src/vector/ConstantVector.test.ts new file mode 100644 index 0000000..b1b9422 --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.test.ts @@ -0,0 +1,17 @@ +import { ConstantVector } from './ConstantVector'; + +describe('ConstantVector', () => { + it('should support constant values', () => { + const value = 3.5; + const v = new ConstantVector(value, 7); + expect(v.length).toEqual(7); + + expect(v.get(0)).toEqual(value); + expect(v.get(1)).toEqual(value); + + // Now check all of them + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(value); + } + }); +}); diff --git a/packages/grafana-data/src/vector/ConstantVector.ts b/packages/grafana-data/src/vector/ConstantVector.ts new file mode 100644 index 0000000..2024514 --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.ts @@ -0,0 +1,25 @@ +import { Vector } from '../types/vector'; + +/** + * @public + */ +export class ConstantVector implements Vector { + constructor(private value: T, private len: number) {} + + get length() { + return this.len; + } + + get(index: number): T { + return this.value; + } + + toArray(): T[] { + const arr = new Array(this.length); + return arr.fill(this.value); + } + + toJSON(): T[] { + return this.toArray(); + } +} diff --git a/packages/grafana-data/src/vector/FormattedVector.ts b/packages/grafana-data/src/vector/FormattedVector.ts new file mode 100644 index 0000000..f30e958 --- /dev/null +++ b/packages/grafana-data/src/vector/FormattedVector.ts @@ -0,0 +1,22 @@ +import { Vector } from '../types/vector'; +import { DisplayProcessor } from '../types'; +import { formattedValueToString } from '../valueFormats'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * @public + */ +export class FormattedVector extends FunctionalVector { + constructor(private source: Vector, private formatter: DisplayProcessor) { + super(); + } + + get length() { + return this.source.length; + } + + get(index: number): string { + const v = this.source.get(index); + return formattedValueToString(this.formatter(v)); + } +} diff --git a/packages/grafana-data/src/vector/FunctionalVector.ts b/packages/grafana-data/src/vector/FunctionalVector.ts new file mode 100644 index 0000000..6569881 --- /dev/null +++ b/packages/grafana-data/src/vector/FunctionalVector.ts @@ -0,0 +1,77 @@ +import { vectorToArray } from './vectorToArray'; +import { Vector } from '../types'; + +export abstract class FunctionalVector implements Vector, Iterable { + abstract get length(): number; + + abstract get(index: number): T; + + // Implement "iterator protocol" + *iterator() { + for (let i = 0; i < this.length; i++) { + yield this.get(i); + } + } + + // Implement "iterable protocol" + [Symbol.iterator]() { + return this.iterator(); + } + + forEach(iterator: (row: T) => void) { + return vectorator(this).forEach(iterator); + } + + map(transform: (item: T, index: number) => V) { + return vectorator(this).map(transform); + } + + filter(predicate: (item: T) => V) { + return vectorator(this).filter(predicate); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): any { + return this.toArray(); + } +} + +/** + * Use functional programming with your vector + */ +export function vectorator(vector: Vector) { + return { + *[Symbol.iterator]() { + for (let i = 0; i < vector.length; i++) { + yield vector.get(i); + } + }, + + forEach(iterator: (row: T) => void) { + for (let i = 0; i < vector.length; i++) { + iterator(vector.get(i)); + } + }, + + map(transform: (item: T, index: number) => V) { + const result: V[] = []; + for (let i = 0; i < vector.length; i++) { + result.push(transform(vector.get(i), i)); + } + return result; + }, + + filter(predicate: (item: T) => V) { + const result: T[] = []; + for (const val of this) { + if (predicate(val)) { + result.push(val); + } + } + return result; + }, + }; +} diff --git a/packages/grafana-data/src/vector/IndexVector.ts b/packages/grafana-data/src/vector/IndexVector.ts new file mode 100644 index 0000000..df695fc --- /dev/null +++ b/packages/grafana-data/src/vector/IndexVector.ts @@ -0,0 +1,36 @@ +import { Field, FieldType } from '../types'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * IndexVector is a simple vector implementation that returns the index value + * for each element in the vector. It is functionally equivolant a vector backed + * by an array with values: `[0,1,2,...,length-1]` + */ +export class IndexVector extends FunctionalVector { + constructor(private len: number) { + super(); + } + + get length() { + return this.len; + } + + get(index: number): number { + return index; + } + + /** + * Returns a field representing the range [0 ... length-1] + */ + static newField(len: number): Field { + return { + name: '', + values: new IndexVector(len), + type: FieldType.number, + config: { + min: 0, + max: len - 1, + }, + }; + } +} diff --git a/packages/grafana-data/src/vector/RowVector.ts b/packages/grafana-data/src/vector/RowVector.ts new file mode 100644 index 0000000..d05904c --- /dev/null +++ b/packages/grafana-data/src/vector/RowVector.ts @@ -0,0 +1,28 @@ +import { Vector } from '../types'; +import { vectorToArray } from './vectorToArray'; + +/** + * RowVector makes the row values look like a vector + * @internal + */ +export class RowVector implements Vector { + constructor(private columns: Vector[]) {} + + rowIndex = 0; + + get length(): number { + return this.columns.length; + } + + get(index: number): number { + return this.columns[index].get(this.rowIndex); + } + + toArray(): number[] { + return vectorToArray(this); + } + + toJSON(): number[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/SortedVector.ts b/packages/grafana-data/src/vector/SortedVector.ts new file mode 100644 index 0000000..33b97f6 --- /dev/null +++ b/packages/grafana-data/src/vector/SortedVector.ts @@ -0,0 +1,25 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +/** + * Values are returned in the order defined by the input parameter + */ +export class SortedVector implements Vector { + constructor(private source: Vector, private order: number[]) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.source.get(this.order[index]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/index.ts b/packages/grafana-data/src/vector/index.ts new file mode 100644 index 0000000..5401189 --- /dev/null +++ b/packages/grafana-data/src/vector/index.ts @@ -0,0 +1,11 @@ +export * from './AppendedVectors'; +export * from './ArrayVector'; +export * from './CircularVector'; +export * from './ConstantVector'; +export * from './BinaryOperationVector'; +export * from './SortedVector'; +export * from './FormattedVector'; +export * from './IndexVector'; +export * from './AsNumberVector'; + +export { vectorator } from './FunctionalVector'; diff --git a/packages/grafana-data/src/vector/vectorToArray.ts b/packages/grafana-data/src/vector/vectorToArray.ts new file mode 100644 index 0000000..a0a17d7 --- /dev/null +++ b/packages/grafana-data/src/vector/vectorToArray.ts @@ -0,0 +1,9 @@ +import { Vector } from '../types/vector'; + +export function vectorToArray(v: Vector): T[] { + const arr: T[] = Array(v.length); + for (let i = 0; i < v.length; i++) { + arr[i] = v.get(i); + } + return arr; +} diff --git a/packages/grafana-data/tsconfig.build.json b/packages/grafana-data/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-data/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json new file mode 100644 index 0000000..b2ebe33 --- /dev/null +++ b/packages/grafana-data/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types", "types"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": [ + "src/**/*.ts*", + "typings/jest", + "../../public/app/types/jquery/*.ts", + "../../public/app/types/sanitize-url.d.ts" + ] +} diff --git a/packages/grafana-data/typings/jest/index.d.ts b/packages/grafana-data/typings/jest/index.d.ts new file mode 100644 index 0000000..b6cdf70 --- /dev/null +++ b/packages/grafana-data/typings/jest/index.d.ts @@ -0,0 +1,17 @@ +import { Observable } from 'rxjs'; + +type ObservableType = T extends Observable ? V : never; + +declare global { + namespace jest { + interface Matchers { + toEmitValues>(expected: E[]): Promise; + /** + * Collect all the values emitted by the observables (also errors) and pass them to the expectations functions after + * the observable ended (or emitted error). If Observable does not complete within OBSERVABLE_TEST_TIMEOUT_IN_MS the + * test fails. + */ + toEmitValuesWith>(expectations: (received: E[]) => void): Promise; + } + } +} diff --git a/packages/grafana-e2e-selectors/CHANGELOG.md b/packages/grafana-e2e-selectors/CHANGELOG.md new file mode 100644 index 0000000..139597f --- /dev/null +++ b/packages/grafana-e2e-selectors/CHANGELOG.md @@ -0,0 +1,2 @@ + + diff --git a/packages/grafana-e2e-selectors/LICENSE_APACHE2 b/packages/grafana-e2e-selectors/LICENSE_APACHE2 new file mode 100644 index 0000000..373dde5 --- /dev/null +++ b/packages/grafana-e2e-selectors/LICENSE_APACHE2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/grafana-e2e-selectors/README.md b/packages/grafana-e2e-selectors/README.md new file mode 100644 index 0000000..60c35d7 --- /dev/null +++ b/packages/grafana-e2e-selectors/README.md @@ -0,0 +1,3 @@ +# Grafana End-to-End Test Selectors library + +> **@grafana/e2e-selectors is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! diff --git a/packages/grafana-e2e-selectors/api-extractor.json b/packages/grafana-e2e-selectors/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-e2e-selectors/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-e2e-selectors/index.js b/packages/grafana-e2e-selectors/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-e2e-selectors/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json new file mode 100644 index 0000000..20fdd92 --- /dev/null +++ b/packages/grafana-e2e-selectors/package.json @@ -0,0 +1,49 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/e2e-selectors", + "version": "8.0.0-beta.1", + "description": "Grafana End-to-End Test Selectors Library", + "keywords": [ + "cli", + "grafana", + "e2e", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-e2e-selectors" + }, + "main": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=e2e-selectors", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/node": "13.7.7", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/systemjs": "^0.20.6", + "pretty-format": "25.1.0", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "ts-loader": "6.2.1", + "ts-node": "9.0.0" + }, + "types": "src/index.ts", + "dependencies": { + "@grafana/tsconfig": "^1.0.0-rc1", + "commander": "5.0.0", + "execa": "4.0.0", + "typescript": "4.2.4", + "yaml": "^1.8.3" + } +} diff --git a/packages/grafana-e2e-selectors/rollup.config.ts b/packages/grafana-e2e-selectors/rollup.config.ts new file mode 100644 index 0000000..41c1a50 --- /dev/null +++ b/packages/grafana-e2e-selectors/rollup.config.ts @@ -0,0 +1,25 @@ +import resolve from '@rollup/plugin-node-resolve'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + file: `dist/index.${env}.js`, + name: libraryName, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + ], + plugins: [resolve(), sourceMaps(), env === 'production' && terser()], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-e2e-selectors/src/index.ts b/packages/grafana-e2e-selectors/src/index.ts new file mode 100644 index 0000000..8c17fa0 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/index.ts @@ -0,0 +1,7 @@ +/** + * A library containing the different design components of the Grafana ecosystem. + * + * @packageDocumentation + */ +export * from './selectors'; +export * from './types'; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts new file mode 100644 index 0000000..3d4748b --- /dev/null +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -0,0 +1,212 @@ +export const Components = { + TimePicker: { + openButton: 'TimePicker Open Button', + }, + DataSource: { + TestData: { + QueryTab: { + scenarioSelectContainer: 'Test Data Query scenario select container', + scenarioSelect: 'Test Data Query scenario select', + max: 'TestData max', + min: 'TestData min', + noise: 'TestData noise', + seriesCount: 'TestData series count', + spread: 'TestData spread', + startValue: 'TestData start value', + }, + }, + Jaeger: { + traceIDInput: 'Trace ID', + }, + }, + Menu: { + MenuComponent: (title: string) => `${title} menu`, + MenuGroup: (title: string) => `${title} menu group`, + MenuItem: (title: string) => `${title} menu item`, + }, + Panels: { + Panel: { + title: (title: string) => `Panel header title item ${title}`, + headerItems: (item: string) => `Panel header item ${item}`, + containerByTitle: (title: string) => `Panel container title ${title}`, + headerCornerInfo: (mode: string) => `Panel header ${mode}`, + }, + Visualization: { + Graph: { + VisualizationTab: { + legendSection: 'Legend section', + }, + Legend: { + legendItemAlias: (name: string) => `gpl alias ${name}`, + showLegendSwitch: 'gpl show legend', + }, + xAxis: { + labels: () => 'div.flot-x-axis > div.flot-tick-label', + }, + }, + BarGauge: { + value: 'Bar gauge value', + }, + PieChart: { + svgSlice: 'Pie Chart Slice', + }, + Text: { + container: () => '.markdown-html', + }, + Table: { + header: 'table header', + }, + }, + }, + VizLegend: { + seriesName: (name: string) => `VizLegend series ${name}`, + }, + Drawer: { + General: { + title: (title: string) => `Drawer title ${title}`, + expand: 'Drawer expand', + contract: 'Drawer contract', + close: 'Drawer close', + rcContentWrapper: () => '.drawer-content-wrapper', + }, + }, + PanelEditor: { + General: { + content: 'Panel editor content', + }, + OptionsPane: { + content: 'Panel editor option pane content', + select: 'Panel editor option pane select', + fieldLabel: (type: string) => `${type} field property editor`, + }, + // not sure about the naming *DataPane* + DataPane: { + content: 'Panel editor data pane content', + }, + applyButton: 'panel editor apply', + toggleVizPicker: 'toggle-viz-picker', + toggleVizOptions: 'toggle-viz-options', + toggleTableView: 'toggle-table-view', + }, + PanelInspector: { + Data: { + content: 'Panel inspector Data content', + }, + Stats: { + content: 'Panel inspector Stats content', + }, + Json: { + content: 'Panel inspector Json content', + }, + Query: { + content: 'Panel inspector Query content', + refreshButton: 'Panel inspector Query refresh button', + jsonObjectKeys: () => '.json-formatter-key', + }, + }, + Tab: { + title: (title: string) => `Tab ${title}`, + active: () => '[class*="-activeTabStyle"]', + }, + RefreshPicker: { + runButton: 'RefreshPicker run button', + }, + QueryTab: { + content: 'Query editor tab content', + queryInspectorButton: 'Query inspector button', + addQuery: 'Query editor add query button', + }, + QueryEditorRows: { + rows: 'Query editor row', + }, + QueryEditorRow: { + actionButton: (title: string) => `${title} query operation action`, + title: (refId: string) => `Query editor row title ${refId}`, + }, + AlertTab: { + content: 'Alert editor tab content', + }, + Alert: { + alert: (severity: string) => `Alert ${severity}`, + }, + TransformTab: { + content: 'Transform editor tab content', + newTransform: (name: string) => `New transform ${name}`, + transformationEditor: (name: string) => `Transformation editor ${name}`, + transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`, + }, + Transforms: { + card: (name: string) => `New transform ${name}`, + Reduce: { + modeLabel: 'Transform mode label', + calculationsLabel: 'Transform calculations label', + }, + searchInput: 'search transformations', + }, + PageToolbar: { + container: () => '.page-toolbar', + item: (tooltip: string) => `Page toolbar button ${tooltip}`, + }, + QueryEditorToolbarItem: { + button: (title: string) => `QueryEditor toolbar item button ${title}`, + }, + BackButton: { + backArrow: 'Go Back button', + }, + OptionsGroup: { + toggle: (title?: string) => (title ? `Options group ${title}` : 'Options group'), + }, + PluginVisualization: { + item: (title: string) => `Plugin visualization item ${title}`, + current: () => '[class*="-currentVisualizationItem"]', + }, + Select: { + option: 'Select option', + input: () => 'input[id*="react-select-"]', + singleValue: () => 'div[class*="-singleValue"]', + }, + FieldConfigEditor: { + content: 'Field config editor content', + }, + OverridesConfigEditor: { + content: 'Field overrides editor content', + }, + FolderPicker: { + container: 'Folder picker select container', + }, + DataSourcePicker: { + container: 'Data source picker select container', + }, + TimeZonePicker: { + container: 'Time zone picker select container', + }, + TraceViewer: { + spanBar: () => '[data-test-id="SpanBar--wrapper"]', + }, + QueryField: { container: 'Query field' }, + ValuePicker: { + button: (name: string) => `Value picker button ${name}`, + select: (name: string) => `Value picker select ${name}`, + }, + Search: { + section: 'Search section', + items: 'Search items', + }, + DashboardLinks: { + container: 'Dashboard link container', + dropDown: 'Dashboard link dropdown', + link: 'Dashboard link', + }, + LoadingIndicator: { + icon: 'Loading indicator', + }, + CallToActionCard: { + button: (name: string) => `Call to action button ${name}`, + }, + DataLinksContextMenu: { + singleLink: 'Data link', + }, + CodeEditor: { + container: 'Code editor container', + }, +}; diff --git a/packages/grafana-e2e-selectors/src/selectors/index.ts b/packages/grafana-e2e-selectors/src/selectors/index.ts new file mode 100644 index 0000000..6404693 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/selectors/index.ts @@ -0,0 +1,8 @@ +import { Pages } from './pages'; +import { Components } from './components'; +import { E2ESelectors } from '../types'; + +export const selectors: { pages: E2ESelectors; components: E2ESelectors } = { + pages: Pages, + components: Components, +}; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts new file mode 100644 index 0000000..93ced01 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -0,0 +1,163 @@ +import { Components } from './components'; + +export const Pages = { + Login: { + url: '/login', + username: 'Username input field', + password: 'Password input field', + submit: 'Login button', + skip: 'Skip change password button', + }, + Home: { + url: '/', + }, + DataSource: { + name: 'Data source settings page name input field', + delete: 'Data source settings page Delete button', + readOnly: 'Data source settings page read only message', + saveAndTest: 'Data source settings page Save and Test button', + alert: 'Data source settings page Alert', + }, + DataSources: { + url: '/datasources', + dataSources: (dataSourceName: string) => `Data source list item ${dataSourceName}`, + }, + AddDataSource: { + url: '/datasources/new', + dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`, + }, + ConfirmModal: { + delete: 'Confirm Modal Danger Button', + }, + AddDashboard: { + url: '/dashboard/new', + addNewPanel: 'Add new panel', + }, + Dashboard: { + url: (uid: string) => `/d/${uid}`, + DashNav: { + nav: 'Dashboard navigation', + }, + SubMenu: { + submenu: 'Dashboard submenu', + submenuItem: 'Dashboard template variables submenu item', + submenuItemLabels: (item: string) => `Dashboard template variables submenu Label ${item}`, + submenuItemValueDropDownValueLinkTexts: (item: string) => + `Dashboard template variables Variable Value DropDown value link text ${item}`, + submenuItemValueDropDownDropDown: 'Dashboard template variables Variable Value DropDown DropDown', + submenuItemValueDropDownOptionTexts: (item: string) => + `Dashboard template variables Variable Value DropDown option text ${item}`, + }, + Settings: { + General: { + deleteDashBoard: 'Dashboard settings page delete dashboard button', + sectionItems: (item: string) => `Dashboard settings section item ${item}`, + saveDashBoard: 'Dashboard settings aside actions Save button', + saveAsDashBoard: 'Dashboard settings aside actions Save As button', + timezone: 'Time zone picker select container', + title: 'Dashboard settings page title', + }, + Annotations: { + List: { + addAnnotationCTA: Components.CallToActionCard.button('Add Annotation Query'), + }, + }, + Variables: { + List: { + addVariableCTA: Components.CallToActionCard.button('Add variable'), + newButton: 'Variable editor New variable button', + table: 'Variable editor Table', + tableRowNameFields: (variableName: string) => `Variable editor Table Name field ${variableName}`, + tableRowDefinitionFields: (variableName: string) => `Variable editor Table Definition field ${variableName}`, + tableRowArrowUpButtons: (variableName: string) => `Variable editor Table ArrowUp button ${variableName}`, + tableRowArrowDownButtons: (variableName: string) => `Variable editor Table ArrowDown button ${variableName}`, + tableRowDuplicateButtons: (variableName: string) => `Variable editor Table Duplicate button ${variableName}`, + tableRowRemoveButtons: (variableName: string) => `Variable editor Table Remove button ${variableName}`, + }, + Edit: { + General: { + headerLink: 'Variable editor Header link', + modeLabelNew: 'Variable editor Header mode New', + modeLabelEdit: 'Variable editor Header mode Edit', + generalNameInput: 'Variable editor Form Name field', + generalTypeSelect: 'Variable editor Form Type select', + generalLabelInput: 'Variable editor Form Label field', + generalHideSelect: 'Variable editor Form Hide select', + selectionOptionsMultiSwitch: 'Variable editor Form Multi switch', + selectionOptionsIncludeAllSwitch: 'Variable editor Form IncludeAll switch', + selectionOptionsCustomAllInput: 'Variable editor Form IncludeAll field', + previewOfValuesOption: 'Variable editor Preview of Values option', + submitButton: 'Variable editor Submit button', + }, + QueryVariable: { + queryOptionsDataSourceSelect: Components.DataSourcePicker.container, + queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select', + queryOptionsRegExInput: 'Variable editor Form Query RegEx field', + queryOptionsSortSelect: 'Variable editor Form Query Sort select', + queryOptionsQueryInput: 'Variable editor Form Default Variable Query Editor textarea', + valueGroupsTagsEnabledSwitch: 'Variable editor Form Query UseTags switch', + valueGroupsTagsTagsQueryInput: 'Variable editor Form Query TagsQuery field', + valueGroupsTagsTagsValuesQueryInput: 'Variable editor Form Query TagsValuesQuery field', + }, + ConstantVariable: { + constantOptionsQueryInput: 'Variable editor Form Constant Query field', + }, + TextBoxVariable: { + textBoxOptionsQueryInput: 'Variable editor Form TextBox Query field', + }, + }, + }, + }, + }, + Dashboards: { + url: '/dashboards', + dashboards: (title: string) => `Dashboard search item ${title}`, + }, + SaveDashboardAsModal: { + newName: 'Save dashboard title field', + save: 'Save dashboard button', + }, + SaveDashboardModal: { + save: 'Dashboard settings Save Dashboard Modal Save button', + saveVariables: 'Dashboard settings Save Dashboard Modal Save variables checkbox', + saveTimerange: 'Dashboard settings Save Dashboard Modal Save timerange checkbox', + }, + SharePanelModal: { + linkToRenderedImage: 'Link to rendered image', + }, + Explore: { + url: '/explore', + General: { + container: 'Explore', + graph: 'Explore Graph', + table: 'Explore Table', + scrollBar: () => '.scrollbar-view', + }, + Toolbar: { + navBar: () => '.explore-toolbar', + }, + }, + SoloPanel: { + url: (page: string) => `/d-solo/${page}`, + }, + PluginsList: { + page: 'Plugins list page', + list: 'Plugins list', + listItem: 'Plugins list item', + signatureErrorNotice: 'Unsigned plugins notice', + }, + PluginPage: { + page: 'Plugin page', + signatureInfo: 'Plugin signature info', + }, + PlaylistForm: { + name: 'Playlist name', + interval: 'Playlist interval', + itemRow: 'Playlist item row', + itemIdType: 'Playlist item dashboard by ID type', + itemTagType: 'Playlist item dashboard by Tag type', + itemMoveUp: 'Move playlist item order up', + itemMoveDown: 'Move playlist item order down', + itemDelete: 'Delete playlist item', + }, +}; diff --git a/packages/grafana-e2e-selectors/src/types/index.ts b/packages/grafana-e2e-selectors/src/types/index.ts new file mode 100644 index 0000000..8c9698f --- /dev/null +++ b/packages/grafana-e2e-selectors/src/types/index.ts @@ -0,0 +1 @@ +export * from './selectors'; diff --git a/packages/grafana-e2e-selectors/src/types/selectors.ts b/packages/grafana-e2e-selectors/src/types/selectors.ts new file mode 100644 index 0000000..e58a925 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/types/selectors.ts @@ -0,0 +1,15 @@ +export type StringSelector = string; +export type FunctionSelector = (id: string) => string; +export type CssSelector = () => string; + +export interface Selectors { + [key: string]: StringSelector | FunctionSelector | CssSelector | UrlSelector | Selectors; +} + +export type E2ESelectors = { + [P in keyof S]: S[P]; +}; + +export interface UrlSelector extends Selectors { + url: string | FunctionSelector; +} diff --git a/packages/grafana-e2e-selectors/tsconfig.build.json b/packages/grafana-e2e-selectors/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-e2e-selectors/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-e2e-selectors/tsconfig.json b/packages/grafana-e2e-selectors/tsconfig.json new file mode 100644 index 0000000..a0cb12d --- /dev/null +++ b/packages/grafana-e2e-selectors/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts"] +} diff --git a/packages/grafana-e2e/.gitignore b/packages/grafana-e2e/.gitignore new file mode 100644 index 0000000..9770e5a --- /dev/null +++ b/packages/grafana-e2e/.gitignore @@ -0,0 +1,3 @@ +test/cypress/report.json +test/cypress/screenshots/actual +test/cypress/videos/ diff --git a/packages/grafana-e2e/CHANGELOG.md b/packages/grafana-e2e/CHANGELOG.md new file mode 100644 index 0000000..139597f --- /dev/null +++ b/packages/grafana-e2e/CHANGELOG.md @@ -0,0 +1,2 @@ + + diff --git a/packages/grafana-e2e/LICENSE_APACHE2 b/packages/grafana-e2e/LICENSE_APACHE2 new file mode 100644 index 0000000..373dde5 --- /dev/null +++ b/packages/grafana-e2e/LICENSE_APACHE2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/grafana-e2e/README.md b/packages/grafana-e2e/README.md new file mode 100644 index 0000000..48e8a28 --- /dev/null +++ b/packages/grafana-e2e/README.md @@ -0,0 +1,5 @@ +# Grafana End-to-End Test library + +> **@grafana/e2e is currently in BETA**. + +This package contains an API wrapper built on top of [Cypress](https://www.cypress.io) that simplifies creating end-to-end tests for Grafana. More information can be found [here](https://github.com/grafana/grafana/blob/main/contribute/style-guides/e2e.md). diff --git a/packages/grafana-e2e/api-extractor.json b/packages/grafana-e2e/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-e2e/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-e2e/bin/grafana-e2e.js b/packages/grafana-e2e/bin/grafana-e2e.js new file mode 100755 index 0000000..e2aefb1 --- /dev/null +++ b/packages/grafana-e2e/bin/grafana-e2e.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../cli')(); diff --git a/packages/grafana-e2e/cli.js b/packages/grafana-e2e/cli.js new file mode 100644 index 0000000..32f3a69 --- /dev/null +++ b/packages/grafana-e2e/cli.js @@ -0,0 +1,49 @@ +const execa = require('execa'); +const program = require('commander'); +const resolveBin = require('resolve-as-bin'); +const { resolve, sep } = require('path'); + +const cypress = (commandName, { updateScreenshots }) => { + // Support running an unpublished dev build + const dirname = __dirname.split(sep).pop(); + const projectPath = resolve(`${__dirname}${dirname === 'dist' ? '/..' : ''}`); + + // For plugins/extendConfig + const CWD = `CWD=${process.cwd()}`; + + // For plugins/compareSnapshots + const UPDATE_SCREENSHOTS = `UPDATE_SCREENSHOTS=${updateScreenshots ? 1 : 0}`; + + const cypressOptions = [commandName, '--env', `${CWD},${UPDATE_SCREENSHOTS}`, `--project=${projectPath}`]; + + const execaOptions = { + cwd: __dirname, + stdio: 'inherit', + }; + + return execa(resolveBin('cypress'), cypressOptions, execaOptions) + .then(() => {}) // no return value + .catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +}; + +module.exports = () => { + const updateOption = '-u, --update-screenshots'; + const updateDescription = 'update expected screenshots'; + + program + .command('open') + .description('runs tests within the interactive GUI') + .option(updateOption, updateDescription) + .action((options) => cypress('open', options)); + + program + .command('run') + .description('runs tests from the CLI without the GUI') + .option(updateOption, updateDescription) + .action((options) => cypress('run', options)); + + program.parse(process.argv); +}; diff --git a/packages/grafana-e2e/cypress.json b/packages/grafana-e2e/cypress.json new file mode 100644 index 0000000..3183556 --- /dev/null +++ b/packages/grafana-e2e/cypress.json @@ -0,0 +1,4 @@ +{ + "projectId": "zb7k1c", + "supportFile": "cypress/support/index.ts" +} diff --git a/packages/grafana-e2e/cypress/fixtures/example.json b/packages/grafana-e2e/cypress/fixtures/example.json new file mode 100644 index 0000000..02e4254 --- /dev/null +++ b/packages/grafana-e2e/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/packages/grafana-e2e/cypress/fixtures/long-trace-response.json b/packages/grafana-e2e/cypress/fixtures/long-trace-response.json new file mode 100644 index 0000000..8095553 --- /dev/null +++ b/packages/grafana-e2e/cypress/fixtures/long-trace-response.json @@ -0,0 +1,7592 @@ +{ + "data": [ + { + "traceID": "3fa414edcef6ad90", + "spans": [ + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b26effbab24e95a", + "operationName": "FindTraceByID", + "references": [], + "startTime": 1605873894680581, + "duration": 1820, + "tags": [ + { "key": "component", "type": "string", "value": "gRPC" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0f5c1808567e4403", + "operationName": "FindTraceByID", + "references": [], + "startTime": 1605873894680587, + "duration": 1847, + "tags": [ + { "key": "component", "type": "string", "value": "gRPC" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "59f093577238d61e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683862, + "duration": 10204, + "tags": [], + "logs": [ + { "timestamp": 1605873894683872, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894694063, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1cc731490b1da4c5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683858, + "duration": 10257, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "602204dc8b8fbc6d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683201, + "duration": 11185, + "tags": [], + "logs": [ + { "timestamp": 1605873894683207, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894694385, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "586e5e4c0400de11", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683196, + "duration": 11200, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "779ac3811ce65e40", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683844, + "duration": 10983, + "tags": [ + { "key": "blockID", "type": "string", "value": "20a16df1-a312-4b1a-a2e2-33b55e9f3c8b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894694822, + "fields": [ + { "key": "bytes", "type": "int64", "value": 315664 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "24203526fe09b1e2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682997, + "duration": 12453, + "tags": [], + "logs": [ + { "timestamp": 1605873894683002, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894695448, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0afe9ad5f5b01be7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682993, + "duration": 12466, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "51413d67348a4624", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682986, + "duration": 13059, + "tags": [ + { "key": "blockID", "type": "string", "value": "08b90b09-c56e-4b4a-b95f-3f0409dc9ce9" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894695963, + "fields": [ + { "key": "bytes", "type": "int64", "value": 239824 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "60007a76ffde4644", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682866, + "duration": 13279, + "tags": [], + "logs": [ + { "timestamp": 1605873894682872, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894696144, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "09d7a8c1faef5a84", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682861, + "duration": 13291, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2755efbbfb1b537b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682846, + "duration": 14054, + "tags": [ + { "key": "blockID", "type": "string", "value": "f78b0397-d3ad-4514-9bf4-87b6ea7e920e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894696898, + "fields": [ + { "key": "bytes", "type": "int64", "value": 218440 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25223420e121413a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683188, + "duration": 14278, + "tags": [ + { "key": "blockID", "type": "string", "value": "3ae22086-9266-481a-9725-c921471e4a94" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894697462, + "fields": [ + { "key": "bytes", "type": "int64", "value": 397880 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "17a3baf85848a727", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683030, + "duration": 14724, + "tags": [], + "logs": [ + { "timestamp": 1605873894683033, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894697752, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46ebfa6c443776c4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683027, + "duration": 14734, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "19b1afe02cf639cf", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683883, + "duration": 14279, + "tags": [], + "logs": [ + { "timestamp": 1605873894683889, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698160, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e5a7dd55283f907", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683879, + "duration": 14289, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5085badf0c1dc842", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683657, + "duration": 14886, + "tags": [], + "logs": [ + { "timestamp": 1605873894683663, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698542, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "71a0e94722b662ed", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683653, + "duration": 14897, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57e69d8f17b39563", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683388, + "duration": 15548, + "tags": [], + "logs": [ + { "timestamp": 1605873894683394, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698936, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6fe636103f47e1fc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683384, + "duration": 15558, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "52146a5c1b2c0030", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683284, + "duration": 15701, + "tags": [], + "logs": [ + { "timestamp": 1605873894683290, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698984, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "160fb4c8329a2ea0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683280, + "duration": 15712, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1e283fe0dd8cc773", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683024, + "duration": 16029, + "tags": [ + { "key": "blockID", "type": "string", "value": "9e102b4e-115a-4bda-abd6-aa6221f9e4b7" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699050, + "fields": [ + { "key": "bytes", "type": "int64", "value": 395808 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bae5c35dd7187ba", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683644, + "duration": 15612, + "tags": [ + { "key": "blockID", "type": "string", "value": "b2f5a951-19a0-473d-8830-e1120ab7bf25" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699255, + "fields": [ + { "key": "bytes", "type": "int64", "value": 345992 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5af2c497b60703d9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683842, + "duration": 15628, + "tags": [], + "logs": [ + { "timestamp": 1605873894683848, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894699469, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a64d382dd0239a7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683837, + "duration": 15639, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "04652166eaec115c", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683378, + "duration": 16179, + "tags": [ + { "key": "blockID", "type": "string", "value": "30903640-5e8c-4cf6-9dc8-f84e0e2541c8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699555, + "fields": [ + { "key": "bytes", "type": "int64", "value": 291056 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "650c7f5ec8cc53a5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683871, + "duration": 15807, + "tags": [ + { "key": "blockID", "type": "string", "value": "19b49abb-e17a-4632-a4b9-3ce95208e3cf" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699675, + "fields": [ + { "key": "bytes", "type": "int64", "value": 424248 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b30323ce39314b9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684553, + "duration": 15144, + "tags": [], + "logs": [ + { "timestamp": 1605873894684559, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894699696, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "288816ad36c9020c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684549, + "duration": 15154, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "26e83a54365218ad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683881, + "duration": 16602, + "tags": [], + "logs": [ + { "timestamp": 1605873894683888, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894700482, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e6a2e62081720fd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683878, + "duration": 16613, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "63332243ceed106c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683893, + "duration": 16666, + "tags": [], + "logs": [ + { "timestamp": 1605873894683900, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894700557, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7dbbbda52a6d32ce", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683888, + "duration": 16678, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "195ed27075e44238", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683828, + "duration": 16766, + "tags": [ + { "key": "blockID", "type": "string", "value": "6c5d1290-2b4b-4f33-9798-63b6654e16b4" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894700591, + "fields": [ + { "key": "bytes", "type": "int64", "value": 367848 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "35e5a12a53c6088a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682748, + "duration": 17901, + "tags": [], + "logs": [ + { "timestamp": 1605873894682751, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894700647, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "690fcd8c8dc87ae8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683273, + "duration": 17376, + "tags": [ + { "key": "blockID", "type": "string", "value": "f1db0c64-befe-4790-af19-7b48e57a9558" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894700646, + "fields": [ + { "key": "bytes", "type": "int64", "value": 386448 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "113befce4abfecb2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682745, + "duration": 17911, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "277870fa55872b13", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683865, + "duration": 17440, + "tags": [ + { "key": "blockID", "type": "string", "value": "f05f1d13-0250-492a-abc8-bca24ccf3a15" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894701291, + "fields": [ + { "key": "bytes", "type": "int64", "value": 211672 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "022b6c95374f166d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683879, + "duration": 17471, + "tags": [ + { "key": "blockID", "type": "string", "value": "0cba7eaf-2546-41ac-99d7-673ef23d6e98" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894701347, + "fields": [ + { "key": "bytes", "type": "int64", "value": 406456 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6cee3530fc730d34", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684631, + "duration": 16733, + "tags": [], + "logs": [ + { "timestamp": 1605873894684639, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894701363, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "674b435291a256c4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684627, + "duration": 16745, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1de85b574e5d906c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683373, + "duration": 18328, + "tags": [], + "logs": [ + { "timestamp": 1605873894683380, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894701701, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4c5ac8757f9888b7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683369, + "duration": 18338, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3e5ab83b57207c74", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683042, + "duration": 18823, + "tags": [], + "logs": [ + { "timestamp": 1605873894683045, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894701863, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7d9927e5c258d511", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682730, + "duration": 19136, + "tags": [ + { "key": "blockID", "type": "string", "value": "794e2adc-701e-4c2d-907a-66221b4455d3" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894701864, + "fields": [ + { "key": "bytes", "type": "int64", "value": 289928 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6c9178ed1e68f858", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683039, + "duration": 18834, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "445d4f3f2dc4d0ad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684214, + "duration": 17919, + "tags": [], + "logs": [ + { "timestamp": 1605873894684221, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702132, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "30dd998b2082f2b9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684210, + "duration": 17958, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2ff9bbb6c991a0ea", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683877, + "duration": 18301, + "tags": [], + "logs": [ + { "timestamp": 1605873894683883, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702177, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "310a2399bb07e8bd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683873, + "duration": 18311, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "19021bbbe6310785", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683360, + "duration": 18978, + "tags": [ + { "key": "blockID", "type": "string", "value": "d2212e62-5b1a-41e2-ae43-c0a596125f1b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894702335, + "fields": [ + { "key": "bytes", "type": "int64", "value": 199208 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "021f72c9979124b5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684621, + "duration": 17816, + "tags": [ + { "key": "blockID", "type": "string", "value": "06ebaf3b-4501-4cda-91fb-c48a9d33a99c" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894702434, + "fields": [ + { "key": "bytes", "type": "int64", "value": 384696 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "68a1e78424019eb9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683657, + "duration": 18900, + "tags": [], + "logs": [ + { "timestamp": 1605873894683663, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702556, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "244e73561d0c691d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683653, + "duration": 18910, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5c1d1b2d38dddcfb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683518, + "duration": 19222, + "tags": [], + "logs": [ + { "timestamp": 1605873894683526, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702739, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "364583eecf36b543", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683513, + "duration": 19232, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "22e42286de359dc4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683843, + "duration": 18969, + "tags": [], + "logs": [ + { "timestamp": 1605873894683849, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702812, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7b936283fac4d0ac", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683838, + "duration": 18981, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "660886869edd36cf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684202, + "duration": 18627, + "tags": [ + { "key": "blockID", "type": "string", "value": "f07137b8-7a0b-4199-b1a7-6b7d5b230723" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894702824, + "fields": [ + { "key": "bytes", "type": "int64", "value": 293936 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57ed8902af3a60b5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683549, + "duration": 19426, + "tags": [], + "logs": [ + { "timestamp": 1605873894683554, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702972, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "64cadcdb4f18b2f7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683544, + "duration": 19437, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25f434fb5960aaef", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683933, + "duration": 19303, + "tags": [], + "logs": [ + { "timestamp": 1605873894683939, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894703235, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62afac560d435620", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683929, + "duration": 19314, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "58435ec74d79cc93", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683865, + "duration": 19469, + "tags": [ + { "key": "blockID", "type": "string", "value": "f2a53e6e-e261-4ec2-92bd-97c5a4c4b760" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894703331, + "fields": [ + { "key": "bytes", "type": "int64", "value": 390648 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "578849d0d44400b5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684004, + "duration": 19335, + "tags": [], + "logs": [ + { "timestamp": 1605873894684012, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894703337, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1f5faebfb90378ad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683999, + "duration": 19346, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "41f1eb48b61ef185", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683035, + "duration": 20463, + "tags": [ + { "key": "blockID", "type": "string", "value": "941a63d4-2739-4ba2-9a15-08256b5c9eae" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894703490, + "fields": [ + { "key": "bytes", "type": "int64", "value": 438128 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "71ee8c7b83046da0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683645, + "duration": 19895, + "tags": [ + { "key": "blockID", "type": "string", "value": "151c489c-a86a-49b7-9fa9-31d1714d59ee" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894703538, + "fields": [ + { "key": "bytes", "type": "int64", "value": 325344 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bf030a07aaceb80", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683218, + "duration": 20692, + "tags": [], + "logs": [ + { "timestamp": 1605873894683225, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894703909, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "54b34afd73af12d1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683215, + "duration": 21011, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a7ba0261825c53c", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683505, + "duration": 20615, + "tags": [ + { "key": "blockID", "type": "string", "value": "db0fa030-4607-40e5-998b-47029aa3430e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704118, + "fields": [ + { "key": "bytes", "type": "int64", "value": 411272 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a597269b23b1bcb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683426, + "duration": 20720, + "tags": [], + "logs": [ + { "timestamp": 1605873894683431, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894704146, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66d886579510b6fd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683422, + "duration": 20841, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1ef8e63340342174", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683990, + "duration": 20334, + "tags": [ + { "key": "blockID", "type": "string", "value": "a10ec85d-9fd2-403e-abcd-6f4ec49b0396" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704322, + "fields": [ + { "key": "bytes", "type": "int64", "value": 442360 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46c6de90778460b1", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683830, + "duration": 20675, + "tags": [ + { "key": "blockID", "type": "string", "value": "7e9e0142-15ff-461e-8e05-6c62d920603a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704502, + "fields": [ + { "key": "bytes", "type": "int64", "value": 465744 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "686f3e58fe28940f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894696113, + "duration": 8480, + "tags": [], + "logs": [ + { "timestamp": 1605873894696126, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894704591, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5960c1f5750b1cde", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894696104, + "duration": 8495, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6aa5ddd42d96f825", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683921, + "duration": 20886, + "tags": [ + { "key": "blockID", "type": "string", "value": "43e5ad4f-11d6-4f25-9925-652cb801fd58" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704804, + "fields": [ + { "key": "bytes", "type": "int64", "value": 405344 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "166377800e8e82a7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683214, + "duration": 21686, + "tags": [], + "logs": [ + { "timestamp": 1605873894683221, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894704899, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e84f8676ef1efad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683209, + "duration": 21696, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "713c834576a0d9b0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683037, + "duration": 22197, + "tags": [], + "logs": [ + { "timestamp": 1605873894683045, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894705234, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "209c0e336c71e932", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683033, + "duration": 22209, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bd34d50efadb568", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683414, + "duration": 21894, + "tags": [ + { "key": "blockID", "type": "string", "value": "b432160f-347c-41ad-882e-f1786e4b42b1" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705305, + "fields": [ + { "key": "bytes", "type": "int64", "value": 409104 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "58cee6c544e69e4f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683206, + "duration": 22173, + "tags": [ + { "key": "blockID", "type": "string", "value": "b12afd19-298a-443f-97ce-b5b2e5bc9d79" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705375, + "fields": [ + { "key": "bytes", "type": "int64", "value": 376872 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4e48e93f70e06522", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894696061, + "duration": 9466, + "tags": [ + { "key": "blockID", "type": "string", "value": "45701f45-c93a-4c35-9fed-9cce2c316a19" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705524, + "fields": [ + { "key": "bytes", "type": "int64", "value": 453296 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2422bf6c2ed108c2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683536, + "duration": 20571, + "tags": [ + { "key": "blockID", "type": "string", "value": "51945006-c165-40af-baea-769b3199bf46" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704104, + "fields": [ + { "key": "bytes", "type": "int64", "value": 342200 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42fac7c66e0ca970", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683200, + "duration": 22685, + "tags": [ + { "key": "blockID", "type": "string", "value": "8772aa40-3489-4b12-b685-9f708ae4de75" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705882, + "fields": [ + { "key": "bytes", "type": "int64", "value": 407152 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a86d93e70a1720c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684627, + "duration": 21313, + "tags": [], + "logs": [ + { "timestamp": 1605873894684633, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894705939, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "72991150a8c3cf08", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684622, + "duration": 21322, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3ceac51ce73f994e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683627, + "duration": 22375, + "tags": [], + "logs": [ + { "timestamp": 1605873894683633, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706001, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "704707012227a4f1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683623, + "duration": 22386, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "26cf501f6dcbb968", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683959, + "duration": 22090, + "tags": [], + "logs": [ + { "timestamp": 1605873894683965, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706048, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7ebb1c9d8a55ac56", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683952, + "duration": 22104, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bd01ea1e13ac6fd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683733, + "duration": 22571, + "tags": [], + "logs": [ + { "timestamp": 1605873894683739, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706303, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4f94f7e28081e1af", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683728, + "duration": 22582, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "60fd2b3931676856", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684310, + "duration": 22119, + "tags": [], + "logs": [ + { "timestamp": 1605873894684317, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706428, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "432bc11447588912", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684305, + "duration": 22131, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1e1aa88072a7cefc", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683026, + "duration": 23483, + "tags": [ + { "key": "blockID", "type": "string", "value": "9064347a-7c49-48d8-b348-8d734f7fd542" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894706506, + "fields": [ + { "key": "bytes", "type": "int64", "value": 365672 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1fb49823a6f803bf", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682930, + "duration": 23695, + "tags": [], + "logs": [ + { "timestamp": 1605873894682935, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706622, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7db786f0da6d756d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682926, + "duration": 23705, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01cb21bacc3933da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894697497, + "duration": 9150, + "tags": [], + "logs": [ + { "timestamp": 1605873894697507, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706646, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "260399c49430577a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894697488, + "duration": 9166, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5ba9d86263fc6da1", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684614, + "duration": 22250, + "tags": [ + { "key": "blockID", "type": "string", "value": "ca346cf4-8162-49e5-a0d0-0619d3813794" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894706862, + "fields": [ + { "key": "bytes", "type": "int64", "value": 416472 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "77f27a840cd8b75b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685061, + "duration": 21838, + "tags": [], + "logs": [ + { "timestamp": 1605873894685068, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706897, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4a48a86f95e117f9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685057, + "duration": 21850, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7d1f782957acfe32", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682777, + "duration": 24168, + "tags": [], + "logs": [ + { "timestamp": 1605873894682783, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706944, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "77f8165c15176536", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682773, + "duration": 24206, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7f20dbc684de78c8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683615, + "duration": 23703, + "tags": [ + { "key": "blockID", "type": "string", "value": "f518974f-2e1e-41c8-b70c-cd2088f5a081" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707316, + "fields": [ + { "key": "bytes", "type": "int64", "value": 278728 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70a453eeff8ec687", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684828, + "duration": 22510, + "tags": [], + "logs": [ + { "timestamp": 1605873894684835, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894707337, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0c7d975a67c6d7bc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684823, + "duration": 22520, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7ccb153793c6afd9", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683720, + "duration": 23911, + "tags": [ + { "key": "blockID", "type": "string", "value": "6f72b73b-c5fe-4761-b91f-b92f447441fa" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707629, + "fields": [ + { "key": "bytes", "type": "int64", "value": 451984 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5bf10b9afef405a9", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894697476, + "duration": 10175, + "tags": [ + { "key": "blockID", "type": "string", "value": "61e0a11e-5e88-49c4-ad1d-81636670e642" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707648, + "fields": [ + { "key": "bytes", "type": "int64", "value": 296328 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1aae38562e2b6a1f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683945, + "duration": 23883, + "tags": [ + { "key": "blockID", "type": "string", "value": "7dda9580-666b-42f9-b8a1-1680a0de352f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707823, + "fields": [ + { "key": "bytes", "type": "int64", "value": 402936 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1dc5a0697b5d6161", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682764, + "duration": 25091, + "tags": [ + { "key": "blockID", "type": "string", "value": "bf101e70-4a86-4d88-890c-e976330ba857" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707853, + "fields": [ + { "key": "bytes", "type": "int64", "value": 385288 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3df0c4e2de834172", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684425, + "duration": 23557, + "tags": [], + "logs": [ + { "timestamp": 1605873894684432, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894707980, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "50f5d53109a047da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684421, + "duration": 23568, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "64e62db2206bdda3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682595, + "duration": 25553, + "tags": [], + "logs": [ + { "timestamp": 1605873894682603, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894708147, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "40f0742ab8be92ab", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682589, + "duration": 25564, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b89efb6b9fb16fc", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684815, + "duration": 23341, + "tags": [ + { "key": "blockID", "type": "string", "value": "84b0a7ea-895d-49f9-892c-11f689f0c13f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894708154, + "fields": [ + { "key": "bytes", "type": "int64", "value": 424816 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "33c05fda4c7d3921", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684414, + "duration": 23815, + "tags": [], + "logs": [ + { "timestamp": 1605873894684420, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894708228, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "742995638b3636e6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684409, + "duration": 23825, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1cf9294062a5780b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682920, + "duration": 25427, + "tags": [ + { "key": "blockID", "type": "string", "value": "e43ee3db-63c9-4d2b-a791-99a5a9203e4e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894708341, + "fields": [ + { "key": "bytes", "type": "int64", "value": 396312 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6852631d2c6d1586", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683843, + "duration": 24695, + "tags": [], + "logs": [ + { "timestamp": 1605873894683850, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894708538, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1691ee4e1f907b39", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683839, + "duration": 24706, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bcd55e85df0601a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684400, + "duration": 24493, + "tags": [ + { "key": "blockID", "type": "string", "value": "211313f2-7284-43eb-b9dc-134b5b344524" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894708891, + "fields": [ + { "key": "bytes", "type": "int64", "value": 249032 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7757c670662153b5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682578, + "duration": 26605, + "tags": [ + { "key": "blockID", "type": "string", "value": "99a8b127-bef6-4718-997b-18e5cb6bee81" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894709180, + "fields": [ + { "key": "bytes", "type": "int64", "value": 443904 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "033e809d9deb02fb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683129, + "duration": 26149, + "tags": [], + "logs": [ + { "timestamp": 1605873894683139, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709277, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0701e7633d141024", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683124, + "duration": 26160, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5a1fcbfa2c2e077e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682990, + "duration": 26296, + "tags": [], + "logs": [ + { "timestamp": 1605873894682993, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709285, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2639318a16168a94", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682987, + "duration": 26304, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "573267e2aab9eb37", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683831, + "duration": 25627, + "tags": [ + { "key": "blockID", "type": "string", "value": "9a5df823-d980-4671-b33f-ef92e485232f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894709455, + "fields": [ + { "key": "bytes", "type": "int64", "value": 357872 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3705123c90491605", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683886, + "duration": 25575, + "tags": [], + "logs": [ + { "timestamp": 1605873894683890, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709460, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46138581a74be710", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683883, + "duration": 25585, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "369cd4694f877602", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684325, + "duration": 25173, + "tags": [], + "logs": [ + { "timestamp": 1605873894684385, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709498, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7aab906468c79c5b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894703359, + "duration": 6145, + "tags": [], + "logs": [ + { "timestamp": 1605873894703375, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709503, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57f0ffddbcc40049", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684321, + "duration": 25185, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0c27a77ad2f6bbb3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894703354, + "duration": 6155, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "27f360a42e423410", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894696933, + "duration": 12666, + "tags": [], + "logs": [ + { "timestamp": 1605873894696942, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709598, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7e5086a8bb3eb3b3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894696926, + "duration": 12678, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42f4a2e45bc6b552", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683602, + "duration": 26169, + "tags": [], + "logs": [ + { "timestamp": 1605873894683608, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709771, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "693c3e7a4e085ce6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683598, + "duration": 26180, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7645427b1d8ca012", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894694874, + "duration": 15023, + "tags": [], + "logs": [ + { "timestamp": 1605873894694896, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709897, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e6e130f1e7bf5ca", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894694866, + "duration": 15038, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2155087a44565c8a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894700685, + "duration": 9446, + "tags": [], + "logs": [ + { "timestamp": 1605873894700698, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894710131, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66ed873b2793ee77", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894700678, + "duration": 9459, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "69654d80ac69ec92", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702020, + "duration": 8202, + "tags": [], + "logs": [ + { "timestamp": 1605873894702031, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894710221, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3a0447242878ba00", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894701338, + "duration": 8890, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e73b563bfa4df76", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683106, + "duration": 27358, + "tags": [ + { "key": "blockID", "type": "string", "value": "b89a056f-d8cd-41e9-84ad-445e68d0a0d5" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894710462, + "fields": [ + { "key": "bytes", "type": "int64", "value": 337664 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e958ff5d95860cf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683591, + "duration": 27357, + "tags": [ + { "key": "blockID", "type": "string", "value": "4767ecb2-01d3-450b-b005-6b9219fdfd71" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894710945, + "fields": [ + { "key": "bytes", "type": "int64", "value": 421576 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4854f2803a2439d0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684313, + "duration": 26669, + "tags": [ + { "key": "blockID", "type": "string", "value": "ec9c982f-485f-47b2-be74-a7f203368ede" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894710972, + "fields": [ + { "key": "bytes", "type": "int64", "value": 409016 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62aa1124fbaafe29", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684107, + "duration": 26934, + "tags": [], + "logs": [ + { "timestamp": 1605873894684113, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711040, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "67ee705c301e7e2a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684102, + "duration": 26945, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "417798c3fbab4244", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894696911, + "duration": 14252, + "tags": [ + { "key": "blockID", "type": "string", "value": "6a346739-04b1-4e86-8f87-e182b01cf5cd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894711160, + "fields": [ + { "key": "bytes", "type": "int64", "value": 407144 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "17faaf92fbea2ed9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683730, + "duration": 27707, + "tags": [], + "logs": [ + { "timestamp": 1605873894683736, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711435, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "29d4e2aa59eae59e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683726, + "duration": 27719, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3b9a85f6cd6075b8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894701327, + "duration": 10230, + "tags": [ + { "key": "blockID", "type": "string", "value": "ece056d3-aa27-464b-81a8-643b3ae208e4" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894711554, + "fields": [ + { "key": "bytes", "type": "int64", "value": 359960 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0da2897c85659567", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683377, + "duration": 28594, + "tags": [], + "logs": [ + { "timestamp": 1605873894683384, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711971, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4407d391acba81fc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683373, + "duration": 28605, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1987773829521f8f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699105, + "duration": 12885, + "tags": [], + "logs": [ + { "timestamp": 1605873894699119, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711989, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c9553a6471269c6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699099, + "duration": 12896, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3dedf220c1f51d38", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704853, + "duration": 7356, + "tags": [], + "logs": [ + { "timestamp": 1605873894704879, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712209, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25820f0eebf05ab3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704846, + "duration": 7370, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7027388faf7e1bf1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684937, + "duration": 27418, + "tags": [], + "logs": [ + { "timestamp": 1605873894684943, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712354, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "44c6d6c7e1afb67d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684933, + "duration": 27429, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3f654e75b41629f5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683718, + "duration": 28677, + "tags": [ + { "key": "blockID", "type": "string", "value": "0e5b1fbb-ab10-44b7-89a0-f8932ee26dcf" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712392, + "fields": [ + { "key": "bytes", "type": "int64", "value": 369000 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4fa1d1a031112ab0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682996, + "duration": 29565, + "tags": [], + "logs": [ + { "timestamp": 1605873894683006, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712560, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e0985a0b4168ff2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682988, + "duration": 29577, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7a7bf32e81f4317e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682984, + "duration": 29753, + "tags": [ + { "key": "blockID", "type": "string", "value": "c56f4809-bc48-4f81-9656-a3bbb96ba87e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712734, + "fields": [ + { "key": "bytes", "type": "int64", "value": 406296 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66d4f363dfa46bdb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684041, + "duration": 28730, + "tags": [], + "logs": [ + { "timestamp": 1605873894684111, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712771, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "616b800031f78e5f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684037, + "duration": 28741, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5b0d3da4dac0a4ab", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683364, + "duration": 29595, + "tags": [ + { "key": "blockID", "type": "string", "value": "10e42379-1c35-419e-a26c-2630b9d2cdd2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712956, + "fields": [ + { "key": "bytes", "type": "int64", "value": 354744 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1571e420dca57b9f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683878, + "duration": 29122, + "tags": [ + { "key": "blockID", "type": "string", "value": "adb287c7-69e4-4ed8-8604-c3302c766db2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712996, + "fields": [ + { "key": "bytes", "type": "int64", "value": 300104 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3120fb610c52c9a6", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684415, + "duration": 28590, + "tags": [ + { "key": "blockID", "type": "string", "value": "faebcb3d-444a-4675-8e55-2f46dbcaa1d7" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713002, + "fields": [ + { "key": "bytes", "type": "int64", "value": 410672 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46feff0edeabb674", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683135, + "duration": 29700, + "tags": [], + "logs": [ + { "timestamp": 1605873894683141, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712834, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "32df737f09cd2bf9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683131, + "duration": 29904, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "604de25c9811a395", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699064, + "duration": 14141, + "tags": [ + { "key": "blockID", "type": "string", "value": "7df8ef23-0902-4b4b-92aa-b6c1aeb3c9c2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713203, + "fields": [ + { "key": "bytes", "type": "int64", "value": 436328 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7867c14538ff0c61", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684923, + "duration": 28333, + "tags": [ + { "key": "blockID", "type": "string", "value": "a94e5162-7e01-4bd6-b5c8-1bc3b50c67c6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713253, + "fields": [ + { "key": "bytes", "type": "int64", "value": 433064 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "04e793f4b075b20f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684557, + "duration": 28822, + "tags": [], + "logs": [ + { "timestamp": 1605873894684565, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713378, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3084a10a11a62355", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684553, + "duration": 28832, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0a0b86e5738d630b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685048, + "duration": 28355, + "tags": [ + { "key": "blockID", "type": "string", "value": "36ce4c95-0cb6-4803-bdfd-b316b3c0cc4c" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713400, + "fields": [ + { "key": "bytes", "type": "int64", "value": 293136 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62ea00c2c871a91e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704821, + "duration": 8702, + "tags": [ + { "key": "blockID", "type": "string", "value": "b9c0dc2b-ee12-4876-a517-2902c6fe655e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713521, + "fields": [ + { "key": "bytes", "type": "int64", "value": 415912 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0f54c2d4ac7df141", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683769, + "duration": 29774, + "tags": [], + "logs": [ + { "timestamp": 1605873894683775, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713542, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e650633f1c4cb45", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683764, + "duration": 29784, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4cff4ebd296d36f0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684028, + "duration": 29524, + "tags": [ + { "key": "blockID", "type": "string", "value": "e6200492-f24a-40ef-946a-e89170d1ac54" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713549, + "fields": [ + { "key": "bytes", "type": "int64", "value": 447592 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0da82a874696fec5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684230, + "duration": 29351, + "tags": [], + "logs": [ + { "timestamp": 1605873894684236, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713581, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "20334815e0eb1b97", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684226, + "duration": 29362, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "20de4a897b30c066", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683315, + "duration": 30412, + "tags": [], + "logs": [ + { "timestamp": 1605873894683323, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713726, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6edcc31aa4c96617", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683309, + "duration": 30424, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3594272577366bc9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684491, + "duration": 29257, + "tags": [], + "logs": [ + { "timestamp": 1605873894684498, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713747, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01e9f897c4145c38", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684485, + "duration": 29270, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "53e0bef2bbb77bea", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684901, + "duration": 28911, + "tags": [], + "logs": [ + { "timestamp": 1605873894684908, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713812, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3df7804d8e682193", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684897, + "duration": 28919, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5664530667612f1f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682860, + "duration": 31015, + "tags": [], + "logs": [ + { "timestamp": 1605873894682871, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713875, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4b6340b15001f8c8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682855, + "duration": 31026, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b3d3f0643735e5f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894694842, + "duration": 19190, + "tags": [ + { "key": "blockID", "type": "string", "value": "f0b87e56-00a6-4270-8ce0-b47affb9113e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714027, + "fields": [ + { "key": "bytes", "type": "int64", "value": 310320 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4a4b3e0d2f115bcf", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683987, + "duration": 30363, + "tags": [], + "logs": [ + { "timestamp": 1605873894683994, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714350, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1e0da3179b38449d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683983, + "duration": 30374, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "318fcd8e3bfc42c7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684209, + "duration": 30152, + "tags": [], + "logs": [ + { "timestamp": 1605873894684215, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714360, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "37e82ddc44e6bf60", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684205, + "duration": 30162, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0271272ae09aac5f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684542, + "duration": 29977, + "tags": [ + { "key": "blockID", "type": "string", "value": "bfbb9652-84f8-4145-8091-8197ea922ad3" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714514, + "fields": [ + { "key": "bytes", "type": "int64", "value": 432848 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2d80feb23cbbb7cd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684508, + "duration": 30161, + "tags": [], + "logs": [ + { "timestamp": 1605873894684515, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714668, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01ad9e5d3837c5b6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684503, + "duration": 30172, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42698e68a26de8cf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683756, + "duration": 30942, + "tags": [ + { "key": "blockID", "type": "string", "value": "55f71d63-05b0-4c3a-b79f-a2563307bf40" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714695, + "fields": [ + { "key": "bytes", "type": "int64", "value": 402624 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6ea302c343fec88f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683645, + "duration": 31247, + "tags": [], + "logs": [ + { "timestamp": 1605873894683652, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714891, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "279e17d93d4978da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683641, + "duration": 31258, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "59b29ac1ab225873", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683001, + "duration": 31930, + "tags": [], + "logs": [ + { "timestamp": 1605873894683006, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714930, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4196b1f250632b3e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682998, + "duration": 31938, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "224b550ee6ad2bf2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684889, + "duration": 30088, + "tags": [ + { "key": "blockID", "type": "string", "value": "07baec6a-187a-493b-b160-772936b5a3f0" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714974, + "fields": [ + { "key": "bytes", "type": "int64", "value": 429360 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "368bcd97b5e9dde0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684678, + "duration": 30934, + "tags": [], + "logs": [ + { "timestamp": 1605873894684685, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894715611, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3d88bddf112b8ae2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684672, + "duration": 30946, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "718a103bd19501b2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684196, + "duration": 31424, + "tags": [ + { "key": "blockID", "type": "string", "value": "a85669d8-148b-4d61-a359-8f97c036b880" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894715617, + "fields": [ + { "key": "bytes", "type": "int64", "value": 401280 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2b56997697dd91c0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684472, + "duration": 31151, + "tags": [ + { "key": "blockID", "type": "string", "value": "75911f2c-fc5e-4ef1-bcff-9abad2120f23" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894715621, + "fields": [ + { "key": "bytes", "type": "int64", "value": 397808 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c049cad7edf280e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683630, + "duration": 32119, + "tags": [ + { "key": "blockID", "type": "string", "value": "fdcc5380-c15f-41c2-9a34-623d6cdd2d5a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894715733, + "fields": [ + { "key": "bytes", "type": "int64", "value": 397464 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e3d16e8ed14d90c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702877, + "duration": 12975, + "tags": [], + "logs": [ + { "timestamp": 1605873894702894, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894715852, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7bd595782cdb70c3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699700, + "duration": 16154, + "tags": [], + "logs": [ + { "timestamp": 1605873894699708, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894715853, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "08a9d074d520a512", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702864, + "duration": 12993, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "083316368540b811", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699695, + "duration": 16164, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7f067cadc2b4569d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684535, + "duration": 31520, + "tags": [], + "logs": [ + { "timestamp": 1605873894684540, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716053, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5061bd596bc8a7e7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684531, + "duration": 31530, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4b9772650994e725", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682990, + "duration": 33209, + "tags": [ + { "key": "blockID", "type": "string", "value": "61022db6-4401-40b6-a3a2-1f4cd5ccb430" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716196, + "fields": [ + { "key": "bytes", "type": "int64", "value": 380504 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0e9c6b89215308ba", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683975, + "duration": 32340, + "tags": [ + { "key": "blockID", "type": "string", "value": "f17c848f-2f99-4215-a5e9-1f55d8e15c1e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716311, + "fields": [ + { "key": "bytes", "type": "int64", "value": 447736 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "030573bc0520e3c2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683061, + "duration": 33348, + "tags": [], + "logs": [ + { "timestamp": 1605873894683067, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716409, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0d2e16a8cf201e5a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683057, + "duration": 33357, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0361f359be22f9c8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702474, + "duration": 14135, + "tags": [], + "logs": [ + { "timestamp": 1605873894702483, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716607, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5310c5c355550cad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702463, + "duration": 14156, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "16870d24920c25b8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699686, + "duration": 17072, + "tags": [ + { "key": "blockID", "type": "string", "value": "320a9ecd-a9fd-4c88-8aeb-e8a312dcce0c" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716753, + "fields": [ + { "key": "bytes", "type": "int64", "value": 424544 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62090e9e1c22bb56", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707676, + "duration": 9095, + "tags": [], + "logs": [ + { "timestamp": 1605873894707691, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716771, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "02d91deb1ff0ea76", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707670, + "duration": 9107, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0cc47cc1eb5deb29", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702844, + "duration": 13991, + "tags": [ + { "key": "blockID", "type": "string", "value": "04e21143-53ef-4083-948e-3bbe502c2d44" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716832, + "fields": [ + { "key": "bytes", "type": "int64", "value": 401728 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b27a749f4d1b557", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684297, + "duration": 32550, + "tags": [ + { "key": "blockID", "type": "string", "value": "d1ffbf86-0e11-4b6e-b9ae-8466e7c42a90" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716844, + "fields": [ + { "key": "bytes", "type": "int64", "value": 193992 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3c5f2282a3e7c658", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683345, + "duration": 33584, + "tags": [], + "logs": [ + { "timestamp": 1605873894683352, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716927, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6fc62f7a1ae1a6e9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683340, + "duration": 33596, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "524a9c941765266a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684584, + "duration": 32449, + "tags": [], + "logs": [ + { "timestamp": 1605873894684592, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894717030, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5bcaa6a4a1c06160", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684579, + "duration": 32460, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2d4e045a72c17ff2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684465, + "duration": 32666, + "tags": [ + { "key": "blockID", "type": "string", "value": "4c9f58b7-b944-4692-9d5b-14270bd1b8d6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717127, + "fields": [ + { "key": "bytes", "type": "int64", "value": 436224 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25548a46750dfecb", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684652, + "duration": 32684, + "tags": [ + { "key": "blockID", "type": "string", "value": "ffd8fb66-db97-4451-9a97-bfb6631b82a5" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717332, + "fields": [ + { "key": "bytes", "type": "int64", "value": 434536 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5f4913a50dcd37c8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683050, + "duration": 34487, + "tags": [ + { "key": "blockID", "type": "string", "value": "0255db6b-061e-4ceb-9ae9-598588995be8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717533, + "fields": [ + { "key": "bytes", "type": "int64", "value": 392520 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6750f7ac4a5b50e8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684648, + "duration": 32974, + "tags": [], + "logs": [ + { "timestamp": 1605873894684654, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894717621, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "41f4b72bd0a14291", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684644, + "duration": 32984, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "03fac2f4c91b31b6", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702448, + "duration": 15285, + "tags": [ + { "key": "blockID", "type": "string", "value": "f7da9248-f02e-44df-b243-c1f5f69e0f67" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717730, + "fields": [ + { "key": "bytes", "type": "int64", "value": 366872 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4934a16eeec96b0d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684005, + "duration": 33827, + "tags": [], + "logs": [ + { "timestamp": 1605873894684010, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894717831, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0ebf8034f9944320", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684001, + "duration": 33836, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0cc1f6dfcc153616", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683672, + "duration": 34388, + "tags": [], + "logs": [ + { "timestamp": 1605873894683679, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894718059, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "501e4211325ef503", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683667, + "duration": 34399, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7bcf0390730028ef", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683332, + "duration": 34943, + "tags": [ + { "key": "blockID", "type": "string", "value": "cbeb7cd1-c8b1-4290-be74-4caf7b3f2d69" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718272, + "fields": [ + { "key": "bytes", "type": "int64", "value": 432424 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b30f12cd1728ebf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683995, + "duration": 34418, + "tags": [ + { "key": "blockID", "type": "string", "value": "2dd90b29-ffb5-4a27-bcd7-0950ca151c14" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718411, + "fields": [ + { "key": "bytes", "type": "int64", "value": 210280 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "22a3f914c23d3456", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684570, + "duration": 34106, + "tags": [ + { "key": "blockID", "type": "string", "value": "4fca87b1-ceb1-4290-a3cb-c0a970a7c5a6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718671, + "fields": [ + { "key": "bytes", "type": "int64", "value": 422528 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "169359b95c501fae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683035, + "duration": 35830, + "tags": [], + "logs": [ + { "timestamp": 1605873894683041, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894718864, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "71541fab4a38308f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683031, + "duration": 35841, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3a7fc15a2fb60753", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707659, + "duration": 11315, + "tags": [ + { "key": "blockID", "type": "string", "value": "57b3be9d-2234-4b8b-a380-424f30717e5b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718970, + "fields": [ + { "key": "bytes", "type": "int64", "value": 437088 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "10e57e001b6c6127", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683210, + "duration": 35772, + "tags": [], + "logs": [ + { "timestamp": 1605873894683220, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894718980, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2c3fb8ad983d67fc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683206, + "duration": 35784, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3fca8d21c0827061", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684366, + "duration": 34674, + "tags": [], + "logs": [ + { "timestamp": 1605873894684372, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894719039, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25a226515c2f9150", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684362, + "duration": 34687, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5fb9111ac6a5d18d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683274, + "duration": 35965, + "tags": [], + "logs": [ + { "timestamp": 1605873894683286, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894719238, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6945097dfeae216a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683268, + "duration": 35979, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6dd256468dea419f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684628, + "duration": 34986, + "tags": [ + { "key": "blockID", "type": "string", "value": "0a5b8e26-05d5-4df2-97c1-a57ccb631b5e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894719610, + "fields": [ + { "key": "bytes", "type": "int64", "value": 368392 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "22c3bb99916b1cf9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684954, + "duration": 34724, + "tags": [], + "logs": [ + { "timestamp": 1605873894684961, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894719678, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7786d37aacb34302", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684949, + "duration": 34735, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0b4489a19011e658", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702395, + "duration": 17621, + "tags": [], + "logs": [ + { "timestamp": 1605873894702404, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720015, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5fe309dbb10a8aa0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702385, + "duration": 17639, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "28a88c33b44009c0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684502, + "duration": 35545, + "tags": [], + "logs": [ + { "timestamp": 1605873894684508, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720047, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6d20c9fb0d7d023a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683045, + "duration": 37003, + "tags": [], + "logs": [ + { "timestamp": 1605873894683057, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720047, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7aed634e79451eff", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684353, + "duration": 35695, + "tags": [ + { "key": "blockID", "type": "string", "value": "c4b4a484-2704-49e8-926b-e7bb7a13c520" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720046, + "fields": [ + { "key": "bytes", "type": "int64", "value": 394640 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2361a627270177b2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684498, + "duration": 35556, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6365c636dea9cf69", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683040, + "duration": 37014, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7fd1af0b8e4b13e5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704467, + "duration": 15587, + "tags": [], + "logs": [ + { "timestamp": 1605873894704477, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720054, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4d0b05c2fe988374", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704458, + "duration": 15600, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "45d91fa92cb81841", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683191, + "duration": 36908, + "tags": [ + { "key": "blockID", "type": "string", "value": "abec5c1e-02b5-4165-8b3d-2940d3adb991" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720066, + "fields": [ + { "key": "bytes", "type": "int64", "value": 352392 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4287af315802d4cd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704355, + "duration": 15863, + "tags": [], + "logs": [ + { "timestamp": 1605873894704369, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720218, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62b352c305041dcc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704348, + "duration": 15876, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6ec165f264482f57", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683346, + "duration": 37139, + "tags": [], + "logs": [ + { "timestamp": 1605873894683351, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720484, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7400527184eeef19", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683342, + "duration": 37152, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=4ff7c150586c7e6f; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1561e391ecd756d5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684939, + "duration": 35696, + "tags": [ + { "key": "blockID", "type": "string", "value": "7dbea947-c624-454c-a99a-b2aa0c96c19f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720631, + "fields": [ + { "key": "bytes", "type": "int64", "value": 328592 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57f916fcf19f117f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682643, + "duration": 38011, + "tags": [], + "logs": [ + { "timestamp": 1605873894682653, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720650, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1d4458304925bc0f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682638, + "duration": 38028, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=3ff0fd3a1cdb9b5e; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6f251bfe2c45ae12", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894709481, + "duration": 11241, + "tags": [], + "logs": [ + { "timestamp": 1605873894709489, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720722, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5365877f5f1070a3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894709476, + "duration": 11253, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=5d1a0e533881c649; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5472390246aac5c4", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683265, + "duration": 37540, + "tags": [ + { "key": "blockID", "type": "string", "value": "31cd1597-b435-467c-8726-9fd43cb8f75a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720802, + "fields": [ + { "key": "bytes", "type": "int64", "value": 263520 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "38b14977915ca22c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683471, + "duration": 37385, + "tags": [], + "logs": [ + { "timestamp": 1605873894683477, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720855, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "08ecb88049158355", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683466, + "duration": 37394, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=241c721a4337e64b; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4cb042697154defa", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684490, + "duration": 36497, + "tags": [ + { "key": "blockID", "type": "string", "value": "37430ec1-eb84-4ad4-9bea-64b05bc05f0b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720984, + "fields": [ + { "key": "bytes", "type": "int64", "value": 329440 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01afdbfe975f8d6d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704258, + "duration": 16738, + "tags": [ + { "key": "blockID", "type": "string", "value": "52136585-3c3c-418c-85bb-079c46f30ee8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720994, + "fields": [ + { "key": "bytes", "type": "int64", "value": 271408 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c881037e38b18ad", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894704334, + "duration": 16710, + "tags": [ + { "key": "blockID", "type": "string", "value": "293f4ca9-60cf-4dce-84f9-90d7a8903467" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721042, + "fields": [ + { "key": "bytes", "type": "int64", "value": 448208 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "727cf2a7b14f8891", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683209, + "duration": 37913, + "tags": [], + "logs": [ + { "timestamp": 1605873894683217, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894721121, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "72a3d0dd535ed714", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683196, + "duration": 37928, + "tags": [], + "logs": [ + { "timestamp": 1605873894683202, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894721124, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e28aae41ed950a0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683204, + "duration": 37923, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1034cca4b87566b9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683192, + "duration": 37937, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5477c4334a555c1a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894702369, + "duration": 18817, + "tags": [ + { "key": "blockID", "type": "string", "value": "65c35f00-7bf4-4d7c-884e-57e1f4f386f1" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721184, + "fields": [ + { "key": "bytes", "type": "int64", "value": 343160 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "156254fce90fef6d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683261, + "duration": 38071, + "tags": [ + { "key": "blockID", "type": "string", "value": "5f6da848-1f43-4327-b791-c8607c834469" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721329, + "fields": [ + { "key": "bytes", "type": "int64", "value": 425008 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "18174ee576735b69", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683380, + "duration": 38087, + "tags": [], + "logs": [ + { "timestamp": 1605873894683386, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894721466, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3c984a418432da06", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683376, + "duration": 38097, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2cb4a90ec9e7ed56", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684207, + "duration": 37304, + "tags": [ + { "key": "blockID", "type": "string", "value": "da15aeab-47f3-4150-a3a0-0b899f5728e0" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721505, + "fields": [ + { "key": "bytes", "type": "int64", "value": 435448 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "31a678641a0daa14", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683185, + "duration": 38722, + "tags": [ + { "key": "blockID", "type": "string", "value": "7f859498-9292-4be9-9902-9cc0cc94db7f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721902, + "fields": [ + { "key": "bytes", "type": "int64", "value": 382184 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "27ca437dde2b9612", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683192, + "duration": 38914, + "tags": [ + { "key": "blockID", "type": "string", "value": "723cdf42-e4bc-48dc-bda5-6173eb15dee4" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894722104, + "fields": [ + { "key": "bytes", "type": "int64", "value": 374272 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "777ba94dbf7e2679", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894703342, + "duration": 18950, + "tags": [ + { "key": "blockID", "type": "string", "value": "9ffb7568-b253-46bf-ae30-275a5370abdd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894722288, + "fields": [ + { "key": "bytes", "type": "int64", "value": 296800 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "447a1de4607678e0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894716862, + "duration": 5567, + "tags": [], + "logs": [ + { "timestamp": 1605873894716881, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722428, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4a1cb35fb165238f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894716854, + "duration": 5582, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "263d88ba7760646a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707662, + "duration": 14938, + "tags": [], + "logs": [ + { "timestamp": 1605873894707677, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722599, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7cb0a9332c646221", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707654, + "duration": 14951, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "178dbf7349f30deb", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682835, + "duration": 39846, + "tags": [ + { "key": "blockID", "type": "string", "value": "ae4b5b30-d87d-459f-8bfe-d05f4f169ced" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894722679, + "fields": [ + { "key": "bytes", "type": "int64", "value": 384344 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "150994409f1cb25a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894700618, + "duration": 22118, + "tags": [], + "logs": [ + { "timestamp": 1605873894700633, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722736, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "585e5d65d550b215", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894700613, + "duration": 22129, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "27511615066e34db", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684102, + "duration": 38879, + "tags": [], + "logs": [ + { "timestamp": 1605873894684108, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722980, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "79b947d9ed7866a6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684097, + "duration": 38891, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6f2e6507fe12975c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894717161, + "duration": 5887, + "tags": [], + "logs": [ + { "timestamp": 1605873894717174, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723047, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "00cdeb6a7479c53f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894717155, + "duration": 5899, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "24bddd4ea3487e35", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683371, + "duration": 39804, + "tags": [ + { "key": "blockID", "type": "string", "value": "d7601ebc-c33c-492c-a1da-4aa053533084" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894723171, + "fields": [ + { "key": "bytes", "type": "int64", "value": 365144 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "53d7bccf2fc103c5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894716842, + "duration": 6390, + "tags": [ + { "key": "blockID", "type": "string", "value": "ca64f28a-77dc-4745-abdc-44054c1e5e40" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894723228, + "fields": [ + { "key": "bytes", "type": "int64", "value": 289352 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "552db884462abcc8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683762, + "duration": 39518, + "tags": [], + "logs": [ + { "timestamp": 1605873894683768, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723279, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66bfd77009ceabee", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683756, + "duration": 39531, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "39ecc86ead7ef908", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684607, + "duration": 38759, + "tags": [], + "logs": [ + { "timestamp": 1605873894684613, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723365, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "23e63f9ee6638cc5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684602, + "duration": 38769, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3995f8a937161d18", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685603, + "duration": 37861, + "tags": [], + "logs": [ + { "timestamp": 1605873894685611, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723463, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7d8cbf547f13bab8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685598, + "duration": 37871, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "50ea9bcb501f096f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707639, + "duration": 15983, + "tags": [ + { "key": "blockID", "type": "string", "value": "7ba16a23-7c29-4c4e-a0a6-f5a35697f61e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894723607, + "fields": [ + { "key": "bytes", "type": "int64", "value": 283976 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "36174ad72177e7e0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683065, + "duration": 40713, + "tags": [], + "logs": [ + { "timestamp": 1605873894683103, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723777, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6eb33f7265438984", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683062, + "duration": 40722, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0d173415b42b54df", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684351, + "duration": 39618, + "tags": [], + "logs": [ + { "timestamp": 1605873894684357, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723968, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0353b10977450cf7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684346, + "duration": 39628, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7b094f4ebdce31c3", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894717141, + "duration": 6985, + "tags": [ + { "key": "blockID", "type": "string", "value": "f1a4a13f-5e5d-484e-8b53-1f4f8e1bad37" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724124, + "fields": [ + { "key": "bytes", "type": "int64", "value": 448472 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4bf98f62683b0e8e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894700602, + "duration": 23557, + "tags": [ + { "key": "blockID", "type": "string", "value": "2c8d9e08-28c9-43e0-a38e-48e205e70c0a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724156, + "fields": [ + { "key": "bytes", "type": "int64", "value": 454976 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3d32ea43a7ace6a3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685122, + "duration": 39094, + "tags": [], + "logs": [ + { "timestamp": 1605873894685153, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724214, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42780d9e0c2beb80", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685114, + "duration": 39108, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6d4dfd6622f9d4e5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684594, + "duration": 39780, + "tags": [ + { "key": "blockID", "type": "string", "value": "6926ecb7-efff-41c5-ae95-85e0b8e75bed" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724372, + "fields": [ + { "key": "bytes", "type": "int64", "value": 364000 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "462c7cc77e9bde26", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684089, + "duration": 40384, + "tags": [ + { "key": "blockID", "type": "string", "value": "eacc5319-5c66-4ef0-bdc7-61ebcd665770" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724469, + "fields": [ + { "key": "bytes", "type": "int64", "value": 375312 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "64c5e8cb8a8c9c87", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684553, + "duration": 40087, + "tags": [], + "logs": [ + { "timestamp": 1605873894684559, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724639, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a2abf3fa1e44a02", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684548, + "duration": 40098, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5185d47ca37c94b2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684289, + "duration": 40372, + "tags": [], + "logs": [ + { "timestamp": 1605873894684296, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724661, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "63f3f66dc1b96cb5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684283, + "duration": 40383, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "09dfedf04619fd00", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683527, + "duration": 41169, + "tags": [], + "logs": [ + { "timestamp": 1605873894683532, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724695, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4fcf6b895ba07eb1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683524, + "duration": 41178, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1d3e7eff78cb43af", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684135, + "duration": 40664, + "tags": [], + "logs": [ + { "timestamp": 1605873894684142, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724800, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "43860f9f193430f0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684131, + "duration": 40675, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7ea1f5a8b4dab8a7", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684338, + "duration": 40775, + "tags": [ + { "key": "blockID", "type": "string", "value": "2906f33b-d748-4827-8eb7-de90927a65dd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725108, + "fields": [ + { "key": "bytes", "type": "int64", "value": 431856 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "512c60978bd2eac4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721019, + "duration": 4095, + "tags": [], + "logs": [ + { "timestamp": 1605873894721028, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725112, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7e897df0e96d32b5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721010, + "duration": 4112, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "787b23c8fa301dd7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894706900, + "duration": 18234, + "tags": [], + "logs": [ + { "timestamp": 1605873894706927, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725133, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3ffd9ecc1161334a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683055, + "duration": 42085, + "tags": [ + { "key": "blockID", "type": "string", "value": "26f1bad8-cd58-4801-8785-d52b8d833a90" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725137, + "fields": [ + { "key": "bytes", "type": "int64", "value": 414056 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2f86e3ff470976d3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894706887, + "duration": 18254, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1a42c28bcd21acc8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685104, + "duration": 40144, + "tags": [ + { "key": "blockID", "type": "string", "value": "777d1eb8-cd33-44d5-8e34-2d253bd948a6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725246, + "fields": [ + { "key": "bytes", "type": "int64", "value": 407792 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "445f67ce7c86e918", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684540, + "duration": 40905, + "tags": [ + { "key": "blockID", "type": "string", "value": "d4b28adc-eb54-4e54-8639-40acbe82196a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725443, + "fields": [ + { "key": "bytes", "type": "int64", "value": 312232 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4dc9df94476d41ef", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713432, + "duration": 12062, + "tags": [], + "logs": [ + { "timestamp": 1605873894713448, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725366, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1f47248f05173a34", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713423, + "duration": 12078, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6ab47177b7f5e532", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684151, + "duration": 41357, + "tags": [], + "logs": [ + { "timestamp": 1605873894684157, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725507, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62088478a235ead5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684146, + "duration": 41368, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "021658e91c35b26e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683518, + "duration": 42121, + "tags": [ + { "key": "blockID", "type": "string", "value": "6deff928-b65a-437a-8d56-64d2397d9d1f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725636, + "fields": [ + { "key": "bytes", "type": "int64", "value": 238936 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "48401abd95ffa153", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894712421, + "duration": 13359, + "tags": [], + "logs": [ + { "timestamp": 1605873894712431, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725779, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a6bcedc4fc18a61", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894712416, + "duration": 13371, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "551f266c080ab0c6", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684122, + "duration": 41711, + "tags": [ + { "key": "blockID", "type": "string", "value": "5180765b-50b6-4de3-abab-ceea6086afb8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725830, + "fields": [ + { "key": "bytes", "type": "int64", "value": 393392 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "72970316c65770af", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894706872, + "duration": 18990, + "tags": [ + { "key": "blockID", "type": "string", "value": "541a6a31-d25a-4275-9688-228355c81085" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725860, + "fields": [ + { "key": "bytes", "type": "int64", "value": 280296 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "635a7471f4256c0b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684275, + "duration": 41695, + "tags": [ + { "key": "blockID", "type": "string", "value": "6bf57585-a03d-44e1-bd18-081b679d3e4a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725967, + "fields": [ + { "key": "bytes", "type": "int64", "value": 434432 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7607fc837fc26251", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683802, + "duration": 42223, + "tags": [], + "logs": [ + { "timestamp": 1605873894683808, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726025, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01269c3f9d50434a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683798, + "duration": 42233, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70114fe92b16120e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721062, + "duration": 5033, + "tags": [], + "logs": [ + { "timestamp": 1605873894721068, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726093, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "37a69429cff69860", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721058, + "duration": 5043, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5262951e45efa67d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894720997, + "duration": 5110, + "tags": [ + { "key": "blockID", "type": "string", "value": "5c09e7bd-2f9c-42d3-8d2f-863e93eaa939" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726103, + "fields": [ + { "key": "bytes", "type": "int64", "value": 246840 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1d6a76dd2ca6c3e6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894711590, + "duration": 14746, + "tags": [], + "logs": [ + { "timestamp": 1605873894711600, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726335, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7c75cd286737359f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894711582, + "duration": 14759, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "78415d3812916d77", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707879, + "duration": 18497, + "tags": [], + "logs": [ + { "timestamp": 1605873894707887, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726375, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5f7ac8c4fd5c680a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707874, + "duration": 18513, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b4bc2ee6a63726e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713413, + "duration": 13261, + "tags": [ + { "key": "blockID", "type": "string", "value": "b654e510-386a-470a-98c4-fb9833d21728" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726671, + "fields": [ + { "key": "bytes", "type": "int64", "value": 384056 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5f55f469fc2d6d29", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894712403, + "duration": 14367, + "tags": [ + { "key": "blockID", "type": "string", "value": "3fca3f89-a174-460d-9a87-92ed03555497" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726767, + "fields": [ + { "key": "bytes", "type": "int64", "value": 334896 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "19235cb1f2dccb32", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894711567, + "duration": 15276, + "tags": [ + { "key": "blockID", "type": "string", "value": "de228107-4ff6-449e-9f1f-6ab34765ab68" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726841, + "fields": [ + { "key": "bytes", "type": "int64", "value": 225832 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "29b186beff361524", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699284, + "duration": 27562, + "tags": [], + "logs": [ + { "timestamp": 1605873894699298, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726845, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0acce3e2af327bae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699278, + "duration": 27578, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b343c544d82bb3b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721050, + "duration": 5994, + "tags": [ + { "key": "blockID", "type": "string", "value": "459be32f-b91d-491b-aa61-5389b239eed8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727041, + "fields": [ + { "key": "bytes", "type": "int64", "value": 429792 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62e3ccbe325de3d1", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683746, + "duration": 43412, + "tags": [ + { "key": "blockID", "type": "string", "value": "a1b8740a-6430-4113-93f4-ad9c52e42d62" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727155, + "fields": [ + { "key": "bytes", "type": "int64", "value": 366096 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "310178852fbf88e0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682731, + "duration": 44481, + "tags": [], + "logs": [ + { "timestamp": 1605873894682741, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727211, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42bb5e9919ea40f4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682726, + "duration": 44493, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "150c8cfeedab6a38", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894708915, + "duration": 18374, + "tags": [], + "logs": [ + { "timestamp": 1605873894708922, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727288, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1dda89d441503741", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894708910, + "duration": 18384, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6c1c11a742626433", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707863, + "duration": 19558, + "tags": [ + { "key": "blockID", "type": "string", "value": "ef68962f-224d-4b6c-9dcd-ef9be8607c72" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727419, + "fields": [ + { "key": "bytes", "type": "int64", "value": 423912 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4393a9fa65c8ceae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707853, + "duration": 19639, + "tags": [], + "logs": [ + { "timestamp": 1605873894707866, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727491, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1392dcdbb07bc781", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707848, + "duration": 19650, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0804f1e82c8828e2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713028, + "duration": 14650, + "tags": [], + "logs": [ + { "timestamp": 1605873894713040, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727677, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4e9e2ce15a6e596c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713023, + "duration": 14661, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0c763a5ef614a2f4", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685588, + "duration": 42409, + "tags": [ + { "key": "blockID", "type": "string", "value": "65fbe578-d535-4032-a12f-f249e7405363" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727993, + "fields": [ + { "key": "bytes", "type": "int64", "value": 441088 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "31c1f2fd1bde2d49", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682863, + "duration": 45441, + "tags": [], + "logs": [ + { "timestamp": 1605873894682870, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728303, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a694924a7a45244", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682710, + "duration": 45639, + "tags": [ + { "key": "blockID", "type": "string", "value": "06a1301e-5b48-425e-a506-a4350afe3d0d" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728346, + "fields": [ + { "key": "bytes", "type": "int64", "value": 418168 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "73f7696fdccac589", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682859, + "duration": 45516, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4b52acf382a86008", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684137, + "duration": 44241, + "tags": [ + { "key": "blockID", "type": "string", "value": "57df43b4-8552-49e3-a1cd-f442609deaf2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728373, + "fields": [ + { "key": "bytes", "type": "int64", "value": 425104 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "18688fac379c4a9e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894707836, + "duration": 20550, + "tags": [ + { "key": "blockID", "type": "string", "value": "c53e7db4-34cd-43d0-9383-de5e40936182" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728383, + "fields": [ + { "key": "bytes", "type": "int64", "value": 389928 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5fccf30d8fc010b8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894726139, + "duration": 2327, + "tags": [], + "logs": [ + { "timestamp": 1605873894726153, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728465, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c2bf57fc386ac18", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894726130, + "duration": 2343, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7cfea87d3c8d06ca", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685457, + "duration": 43096, + "tags": [], + "logs": [ + { "timestamp": 1605873894685465, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728552, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "12a80c97c2a42f05", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685452, + "duration": 43107, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "08122694d5e37cb7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894723200, + "duration": 5455, + "tags": [], + "logs": [ + { "timestamp": 1605873894723209, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728654, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "313e6147867246d6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894723194, + "duration": 5466, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e4f73b3315eb612", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894715649, + "duration": 13049, + "tags": [], + "logs": [ + { "timestamp": 1605873894715658, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728698, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7e0457958022516b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894715644, + "duration": 13060, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "04db9d1320bc1720", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683309, + "duration": 45454, + "tags": [], + "logs": [ + { "timestamp": 1605873894683316, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728762, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4702ca2057b120a0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894726116, + "duration": 2652, + "tags": [ + { "key": "blockID", "type": "string", "value": "ec5b73d8-61f8-4210-8838-e6cfd253294b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728766, + "fields": [ + { "key": "bytes", "type": "int64", "value": 173264 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e2c536cf48b42a8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683305, + "duration": 45464, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "69bd1c5d5184626b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894708900, + "duration": 19981, + "tags": [ + { "key": "blockID", "type": "string", "value": "de324468-b889-4f4c-af77-907353a719cd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728878, + "fields": [ + { "key": "bytes", "type": "int64", "value": 307032 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6849c669006c2759", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894714727, + "duration": 14480, + "tags": [], + "logs": [ + { "timestamp": 1605873894714736, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894729206, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6836249c56ca87ae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894714722, + "duration": 14490, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4614a3c3374430a7", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894682850, + "duration": 46413, + "tags": [ + { "key": "blockID", "type": "string", "value": "ea416fc3-eedc-413e-9cc1-d8fd3548cfe6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729260, + "fields": [ + { "key": "bytes", "type": "int64", "value": 392160 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "33a5b0766e98269c", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894685441, + "duration": 44269, + "tags": [ + { "key": "blockID", "type": "string", "value": "b50c3305-8d80-42fc-88a4-97a8604c7066" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729705, + "fields": [ + { "key": "bytes", "type": "int64", "value": 346072 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "38da2ce44e352b40", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894722577, + "duration": 7233, + "tags": [], + "logs": [ + { "timestamp": 1605873894722588, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894729809, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a0fc54146d07c21", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894722569, + "duration": 7247, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "398ed8e3573cf781", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894715632, + "duration": 14249, + "tags": [ + { "key": "blockID", "type": "string", "value": "2d9f964a-aac5-42e1-b410-866ad1706d7a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729879, + "fields": [ + { "key": "bytes", "type": "int64", "value": 441600 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "47c93569ac9ecd04", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713013, + "duration": 16911, + "tags": [ + { "key": "blockID", "type": "string", "value": "96c1b791-af15-4554-a1bb-b0f200625856" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729919, + "fields": [ + { "key": "bytes", "type": "int64", "value": 446504 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3d61171be03f3db4", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894714709, + "duration": 15261, + "tags": [ + { "key": "blockID", "type": "string", "value": "ae957436-40cc-4d15-a3c3-26d10613aaf6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729967, + "fields": [ + { "key": "bytes", "type": "int64", "value": 287976 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3de17f2475734d79", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894723182, + "duration": 6796, + "tags": [ + { "key": "blockID", "type": "string", "value": "eb218dd8-99fa-4de7-87cd-8998b89e0778" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729976, + "fields": [ + { "key": "bytes", "type": "int64", "value": 403400 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "712c995480f17232", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713402, + "duration": 16771, + "tags": [], + "logs": [ + { "timestamp": 1605873894713412, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730172, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "26950bc4bf84c34b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713392, + "duration": 16787, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bd3c2e5acbea9c4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894701374, + "duration": 29019, + "tags": [], + "logs": [ + { "timestamp": 1605873894701383, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730393, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2106e0853647ac08", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894701369, + "duration": 29030, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e59a327558a7329", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894699265, + "duration": 31183, + "tags": [ + { "key": "blockID", "type": "string", "value": "d8b5fee2-e2b3-445c-8ea9-243519a5c104" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894730443, + "fields": [ + { "key": "bytes", "type": "int64", "value": 400224 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "037acce8385b1858", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721017, + "duration": 9605, + "tags": [], + "logs": [ + { "timestamp": 1605873894721028, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730621, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3547a2504168d8c7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721012, + "duration": 9617, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0dc60016513590c8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894722299, + "duration": 8583, + "tags": [ + { "key": "blockID", "type": "string", "value": "69120461-fbd8-4ca0-a5fb-957d745a22a8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894730879, + "fields": [ + { "key": "bytes", "type": "int64", "value": 370472 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5d18bf1b78bd4e75", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684396, + "duration": 46590, + "tags": [], + "logs": [ + { "timestamp": 1605873894684402, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730985, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3bdc5547104db28c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894684392, + "duration": 46600, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "682e6d017ce71dad", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894713268, + "duration": 17796, + "tags": [ + { "key": "blockID", "type": "string", "value": "84644a06-da08-4c3a-936f-70a634d8052d" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731057, + "fields": [ + { "key": "bytes", "type": "int64", "value": 353808 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3bbbdf937ccb2ccb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721935, + "duration": 9258, + "tags": [], + "logs": [ + { "timestamp": 1605873894721944, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731193, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7c40bff5b749a532", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721928, + "duration": 9272, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "31f86900e7598e55", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894701358, + "duration": 29885, + "tags": [ + { "key": "blockID", "type": "string", "value": "186f31e1-409b-4c9c-95b5-abc662389d3b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731240, + "fields": [ + { "key": "bytes", "type": "int64", "value": 434496 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7471b3c5a188e8da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894729995, + "duration": 1327, + "tags": [], + "logs": [ + { "timestamp": 1605873894730003, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731321, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "33cb5e55876f584c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894729989, + "duration": 1338, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "58b7d0b41550e88f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894729978, + "duration": 1396, + "tags": [ + { "key": "blockID", "type": "string", "value": "e0d8f1ac-a48f-4dea-868f-183e1a124fb5" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731373, + "fields": [ + { "key": "bytes", "type": "int64", "value": 16488 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70405f4198f01d16", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721544, + "duration": 9893, + "tags": [], + "logs": [ + { "timestamp": 1605873894721555, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731436, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "56f0f0d7120ea5a5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894721540, + "duration": 9903, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "69bb9bf8c37d9faf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894683022, + "duration": 48646, + "tags": [ + { "key": "blockID", "type": "string", "value": "cf7747f5-68f9-490b-840d-9975c057c7e6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731663, + "fields": [ + { "key": "bytes", "type": "int64", "value": 425640 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4ef61721dfd7f61b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894720840, + "duration": 10841, + "tags": [], + "logs": [ + { "timestamp": 1605873894720869, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731680, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70e8aa6d13e56007", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894720827, + "duration": 10860, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "297ff96c736c18f4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894705333, + "duration": 26573, + "tags": [], + "logs": [ + { "timestamp": 1605873894705342, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731904, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a80209f067be4f5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894705328, + "duration": 26585, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5b3db530e83db855", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894731272, + "duration": 796, + "tags": [], + "logs": [ + { "timestamp": 1605873894731284, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894732067, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b2458a9486a4298", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894731265, + "duration": 886, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3139145bb422702e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b26effbab24e95a" }], + "startTime": 1605873894731251, + "duration": 941, + "tags": [ + { "key": "blockID", "type": "string", "value": "f9fd03ba-91a8-476b-b809-d2b11bfa790d" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894732190, + "fields": [ + { "key": "bytes", "type": "int64", "value": 8680 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + } + ], + "processes": { + "p1": { + "serviceName": "s1" + } + }, + "warnings": null + } + ], + "total": 0, + "limit": 0, + "offset": 0, + "errors": null +} diff --git a/packages/grafana-e2e/cypress/plugins/compareScreenshots.js b/packages/grafana-e2e/cypress/plugins/compareScreenshots.js new file mode 100644 index 0000000..426d12a --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/compareScreenshots.js @@ -0,0 +1,49 @@ +'use strict'; +const BlinkDiff = require('blink-diff'); +const { resolve } = require('path'); + +// @todo use npmjs.com/pixelmatch or an available cypress plugin +const compareScreenshots = async ({ config, screenshotsFolder, specName }) => { + const name = config.name || config; // @todo use `??` + const threshold = config.threshold || 0.001; // @todo use `??` + + const imageAPath = `${screenshotsFolder}/${specName}/${name}.png`; + const imageBPath = resolve(`${screenshotsFolder}/../expected/${specName}/${name}.png`); + + const imageOutputPath = screenshotsFolder.endsWith('actual') ? imageAPath.replace('.png', '.diff.png') : undefined; + + const { code } = await new Promise((resolve, reject) => { + new BlinkDiff({ + imageAPath, + imageBPath, + imageOutputPath, + threshold, + thresholdType: BlinkDiff.THRESHOLD_PERCENT, + }).run((error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + + if (code <= 1) { + let msg = `\nThe screenshot [${imageAPath}] differs from [${imageBPath}]`; + msg += '\n'; + msg += '\nCheck the Artifacts tab in the CircleCi build output for the actual screenshots.'; + msg += '\n'; + msg += '\n If the difference between expected and outcome is NOT acceptable then do the following:'; + msg += '\n - Check the code for changes that causes this difference, fix that and retry.'; + msg += '\n'; + msg += '\n If the difference between expected and outcome is acceptable then do the following:'; + msg += '\n - Replace the expected image with the outcome and retry.'; + msg += '\n'; + throw new Error(msg); + } else { + // Must return a value + return true; + } +}; + +module.exports = compareScreenshots; diff --git a/packages/grafana-e2e/cypress/plugins/extendConfig.js b/packages/grafana-e2e/cypress/plugins/extendConfig.js new file mode 100644 index 0000000..85b9088 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/extendConfig.js @@ -0,0 +1,79 @@ +'use strict'; +const { + promises: { readFile }, +} = require('fs'); +const { resolve } = require('path'); + +// @todo use https://github.com/bahmutov/cypress-extends when possible +module.exports = async (baseConfig) => { + // From CLI + const { + env: { CWD, UPDATE_SCREENSHOTS }, + } = baseConfig; + + if (CWD) { + // @todo: https://github.com/cypress-io/cypress/issues/6406 + const jsonReporter = require.resolve('@mochajs/json-file-reporter'); + + // @todo `baseUrl: env.CYPRESS_BASEURL` + const projectConfig = { + fixturesFolder: `${CWD}/cypress/fixtures`, + integrationFolder: `${CWD}/cypress/integration`, + reporter: jsonReporter, + reporterOptions: { + output: `${CWD}/cypress/report.json`, + }, + screenshotsFolder: `${CWD}/cypress/screenshots/${UPDATE_SCREENSHOTS ? 'expected' : 'actual'}`, + videosFolder: `${CWD}/cypress/videos`, + }; + + const customProjectConfig = await readFile(`${CWD}/cypress.json`, 'utf8') + .then(JSON.parse) + .then((config) => { + const pathKeys = [ + 'fileServerFolder', + 'fixturesFolder', + 'ignoreTestFiles', + 'integrationFolder', + 'pluginsFile', + 'screenshotsFolder', + 'supportFile', + 'testFiles', + 'videosFolder', + ]; + + return Object.fromEntries( + Object.entries(config).map(([key, value]) => { + if (pathKeys.includes(key)) { + return [key, resolve(CWD, value)]; + } else { + return [key, value]; + } + }) + ); + }) + .catch((error) => { + if (error.code === 'ENOENT') { + // File is optional + return {}; + } else { + // Unexpected error + throw error; + } + }); + + return { + ...baseConfig, + ...projectConfig, + ...customProjectConfig, + reporterOptions: { + ...baseConfig.reporterOptions, + ...projectConfig.reporterOptions, + ...customProjectConfig.reporterOptions, + }, + }; + } else { + // Temporary legacy support for Grafana core (using `yarn start`) + return baseConfig; + } +}; diff --git a/packages/grafana-e2e/cypress/plugins/index.js b/packages/grafana-e2e/cypress/plugins/index.js new file mode 100644 index 0000000..c671748 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/index.js @@ -0,0 +1,19 @@ +const compareScreenshots = require('./compareScreenshots'); +const extendConfig = require('./extendConfig'); +const readProvisions = require('./readProvisions'); +const typescriptPreprocessor = require('./typescriptPreprocessor'); + +module.exports = (on, config) => { + on('file:preprocessor', typescriptPreprocessor); + on('task', { compareScreenshots, readProvisions }); + on('task', { + log({ message, optional }) { + optional ? console.log(message, optional) : console.log(message); + return null; + }, + }); + + // Always extend with this library's config and return for diffing + // @todo remove this when possible: https://github.com/cypress-io/cypress/issues/5674 + return extendConfig(config); +}; diff --git a/packages/grafana-e2e/cypress/plugins/readProvisions.js b/packages/grafana-e2e/cypress/plugins/readProvisions.js new file mode 100644 index 0000000..92f6c16 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/readProvisions.js @@ -0,0 +1,14 @@ +'use strict'; +const { parse: parseYml } = require('yaml'); +const { + promises: { readFile }, +} = require('fs'); +const { resolve: resolvePath } = require('path'); + +const readProvision = (filePath) => readFile(filePath, 'utf8').then((contents) => parseYml(contents)); + +const readProvisions = (filePaths) => Promise.all(filePaths.map(readProvision)); + +// Paths are relative to /provisioning +module.exports = ({ CWD, filePaths }) => + readProvisions(filePaths.map((filePath) => resolvePath(CWD, 'provisioning', filePath))); diff --git a/packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js b/packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js new file mode 100644 index 0000000..ed596a1 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js @@ -0,0 +1,39 @@ +const { resolve } = require('path'); +const wp = require('@cypress/webpack-preprocessor'); + +const anyNodeModules = /node_modules/; +const packageRoot = resolve(`${__dirname}/../../`); +const packageModules = `${packageRoot}/node_modules`; + +const webpackOptions = { + module: { + rules: [ + { + include: (modulePath) => { + if (!anyNodeModules.test(modulePath)) { + // Is a file within the project + return true; + } else { + // Is a file within this package + return modulePath.startsWith(packageRoot) && !modulePath.startsWith(packageModules); + } + }, + test: /\.ts$/, + use: [ + { + loader: 'ts-loader', + }, + ], + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, +}; + +const options = { + webpackOptions, +}; + +module.exports = wp(options); diff --git a/packages/grafana-e2e/cypress/support/commands.ts b/packages/grafana-e2e/cypress/support/commands.ts new file mode 100644 index 0000000..a390bbe --- /dev/null +++ b/packages/grafana-e2e/cypress/support/commands.ts @@ -0,0 +1,25 @@ +import 'cypress-file-upload'; + +interface CompareScreenshotsConfig { + name: string; + threshold?: number; +} + +Cypress.Commands.add('compareScreenshots', (config: CompareScreenshotsConfig | string) => { + cy.task('compareScreenshots', { + config, + screenshotsFolder: Cypress.config('screenshotsFolder'), + specName: Cypress.spec.name, + }); +}); + +Cypress.Commands.add('logToConsole', (message: string, optional?: any) => { + cy.task('log', { message: '(' + new Date().toISOString() + ') ' + message, optional }); +}); + +Cypress.Commands.add('readProvisions', (filePaths: string[]) => { + cy.task('readProvisions', { + CWD: Cypress.env('CWD'), + filePaths, + }); +}); diff --git a/packages/grafana-e2e/cypress/support/index.d.ts b/packages/grafana-e2e/cypress/support/index.d.ts new file mode 100644 index 0000000..3a559aa --- /dev/null +++ b/packages/grafana-e2e/cypress/support/index.d.ts @@ -0,0 +1,9 @@ +/// + +declare namespace Cypress { + interface Chainable { + compareScreenshots(config: CompareScreenshotsConfig | string): Chainable; + logToConsole(message: string, optional?: any): void; + readProvisions(filePaths: string[]): Chainable; + } +} diff --git a/packages/grafana-e2e/cypress/support/index.ts b/packages/grafana-e2e/cypress/support/index.ts new file mode 100644 index 0000000..0e38642 --- /dev/null +++ b/packages/grafana-e2e/cypress/support/index.ts @@ -0,0 +1,39 @@ +// yarn build fails with: +// >> /Users/hugo/go/src/github.com/grafana/grafana/node_modules/stringmap/stringmap.js:99 +// >> throw new Error("StringMap expected string key"); +// require('cypress-failed-log'); +import './commands'; + +Cypress.Screenshot.defaults({ + screenshotOnRunFailure: false, +}); + +const COMMAND_DELAY = 1000; + +if (Cypress.env('SLOWMO')) { + const commandsToModify = ['clear', 'click', 'contains', 'reload', 'then', 'trigger', 'type', 'visit']; + + commandsToModify.forEach((command) => { + // @ts-ignore -- https://github.com/cypress-io/cypress/issues/7807 + Cypress.Commands.overwrite(command, (originalFn, ...args) => { + const origVal = originalFn(...args); + + return new Promise((resolve) => { + setTimeout(() => resolve(origVal), COMMAND_DELAY); + }); + }); + }); +} + +// @todo remove when possible: https://github.com/cypress-io/cypress/issues/95 +Cypress.on('window:before:load', (win) => { + // @ts-ignore + delete win.fetch; +}); + +// uncomment below to prevent Cypress from failing tests when unhandled errors are thrown +// Cypress.on('uncaught:exception', (err, runnable) => { +// // returning false here prevents Cypress from +// // failing the test +// return false; +// }); diff --git a/packages/grafana-e2e/cypress/tsconfig.json b/packages/grafana-e2e/cypress/tsconfig.json new file mode 100644 index 0000000..c7e1a52 --- /dev/null +++ b/packages/grafana-e2e/cypress/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "declaration": false, + "module": "commonjs", + "types": ["cypress", "cypress-file-upload"] + }, + "extends": "@grafana/tsconfig", + "include": ["**/*.ts"] +} diff --git a/packages/grafana-e2e/index.js b/packages/grafana-e2e/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-e2e/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json new file mode 100644 index 0000000..d729c2a --- /dev/null +++ b/packages/grafana-e2e/package.json @@ -0,0 +1,60 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/e2e", + "version": "8.0.0-beta.1", + "description": "Grafana End-to-End Test Library", + "keywords": [ + "cli", + "grafana", + "e2e", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-e2e" + }, + "main": "src/index.ts", + "bin": { + "grafana-e2e": "bin/grafana-e2e.js" + }, + "scripts": { + "build": "grafana-toolkit package:build --scope=e2e", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "open": "cypress open", + "start": "cypress run --headless --browser chrome", + "test": "pushd test && node ../dist/bin/grafana-e2e.js run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/node": "13.7.7", + "@types/rollup-plugin-visualizer": "2.6.0", + "rollup": "2.33.3", + "rollup-plugin-copy": "3.3.0", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0" + }, + "types": "src/index.ts", + "dependencies": { + "@cypress/webpack-preprocessor": "4.1.3", + "@grafana/e2e-selectors": "8.0.0-beta.1", + "@grafana/tsconfig": "^1.0.0-rc1", + "@mochajs/json-file-reporter": "^1.2.0", + "blink-diff": "1.0.13", + "commander": "5.0.0", + "cypress": "^6.3.0", + "cypress-file-upload": "^4.0.7", + "execa": "4.0.0", + "resolve-as-bin": "2.1.0", + "ts-loader": "6.2.1", + "typescript": "4.2.4", + "yaml": "^1.8.3" + } +} diff --git a/packages/grafana-e2e/rollup.config.ts b/packages/grafana-e2e/rollup.config.ts new file mode 100644 index 0000000..341c18b --- /dev/null +++ b/packages/grafana-e2e/rollup.config.ts @@ -0,0 +1,39 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import copy from 'rollup-plugin-copy'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; + +const { name } = require('./package.json'); + +const buildCjsPackage = ({ env }) => ({ + input: 'compiled/index.js', + output: { + file: `dist/index.${env}.js`, + name, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + external: ['@grafana/e2e-selectors'], + plugins: [ + copy({ + flatten: false, + targets: [ + { src: 'bin/**/*.*', dest: 'dist/bin/' }, + { src: 'cli.js', dest: 'dist/' }, + { src: 'cypress.json', dest: 'dist/' }, + { src: 'cypress/**/*.*', dest: 'dist/cypress/' }, + ], + }), + commonjs({ + include: /node_modules/, + }), + resolve(), + sourceMaps(), + env === 'production' && terser(), + ], +}); + +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-e2e/src/components/index.ts b/packages/grafana-e2e/src/components/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts new file mode 100644 index 0000000..1dc1807 --- /dev/null +++ b/packages/grafana-e2e/src/flows/addDashboard.ts @@ -0,0 +1,218 @@ +import { DeleteDashboardConfig } from './deleteDashboard'; +import { e2e } from '../index'; +import { getDashboardUid } from '../support/url'; +import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange'; +import { v4 as uuidv4 } from 'uuid'; + +export interface AddAnnotationConfig { + dataSource: string; + dataSourceForm?: () => void; + name: string; +} + +export interface AddDashboardConfig { + annotations: AddAnnotationConfig[]; + timeRange: TimeRangeConfig; + title: string; + variables: PartialAddVariableConfig[]; +} + +interface AddVariableDefault { + hide: string; + type: string; +} + +interface AddVariableOptional { + constantValue?: string; + dataSource?: string; + label?: string; + query?: string; + regex?: string; +} + +interface AddVariableRequired { + name: string; +} + +export type PartialAddVariableConfig = Partial & AddVariableOptional & AddVariableRequired; +export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired; + +export const addDashboard = (config?: Partial) => { + const fullConfig: AddDashboardConfig = { + annotations: [], + title: `e2e-${uuidv4()}`, + variables: [], + ...config, + timeRange: { + from: '2020-01-01 00:00:00', + to: '2020-01-01 06:00:00', + zone: 'Coordinated Universal Time', + ...config?.timeRange, + }, + }; + + const { annotations, timeRange, title, variables } = fullConfig; + + e2e().logToConsole('Adding dashboard with title:', title); + + e2e.pages.AddDashboard.visit(); + + if (annotations.length > 0 || variables.length > 0) { + e2e.components.PageToolbar.item('Dashboard settings').click(); + addAnnotations(annotations); + + fullConfig.variables = addVariables(variables); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + } + + setDashboardTimeRange(timeRange); + + e2e.components.PageToolbar.item('Save dashboard').click(); + e2e.pages.SaveDashboardAsModal.newName().clear().type(title); + e2e.pages.SaveDashboardAsModal.save().click(); + e2e.flows.assertSuccessNotification(); + + e2e().logToConsole('Added dashboard with title:', title); + + return e2e() + .url() + .then((url: string) => { + const uid = getDashboardUid(url); + + e2e.getScenarioContext().then(({ addedDashboards }: any) => { + e2e.setScenarioContext({ + addedDashboards: [...addedDashboards, { title, uid } as DeleteDashboardConfig], + }); + }); + + // @todo remove `wrap` when possible + return e2e().wrap( + { + config: fullConfig, + uid, + }, + { log: false } + ); + }); +}; + +const addAnnotation = (config: AddAnnotationConfig, isFirst: boolean) => { + if (isFirst) { + e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTA().click(); + } else { + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.btn', 'New').click(); + } + + const { dataSource, dataSourceForm, name } = config; + + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.gf-form', 'Data source').find('select').select(dataSource); + + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.gf-form', 'Name').find('input').type(name); + + if (dataSourceForm) { + dataSourceForm(); + } + + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.btn', 'Add').click(); +}; + +const addAnnotations = (configs: AddAnnotationConfig[]) => { + if (configs.length > 0) { + e2e.pages.Dashboard.Settings.General.sectionItems('Annotations').click(); + } + + return configs.forEach((config, i) => addAnnotation(config, i === 0)); +}; + +export const VARIABLE_HIDE_LABEL = 'Label'; +export const VARIABLE_HIDE_NOTHING = ''; +export const VARIABLE_HIDE_VARIABLE = 'Variable'; + +export const VARIABLE_TYPE_AD_HOC_FILTERS = 'Ad hoc filters'; +export const VARIABLE_TYPE_CONSTANT = 'Constant'; +export const VARIABLE_TYPE_DATASOURCE = 'Datasource'; +export const VARIABLE_TYPE_QUERY = 'Query'; + +const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVariableConfig => { + const fullConfig = { + hide: VARIABLE_HIDE_NOTHING, + type: VARIABLE_TYPE_QUERY, + ...config, + }; + + if (isFirst) { + e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click(); + } else { + e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); + } + + const { constantValue, dataSource, label, name, query, regex, type } = fullConfig; + + // This field is key to many reactive changes + if (type !== VARIABLE_TYPE_QUERY) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('have.text', 'Query').click().type(`${type}{enter}`); + }); + } + + if (label) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label); + } + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type(name); + + if ( + dataSource && + (type === VARIABLE_TYPE_AD_HOC_FILTERS || type === VARIABLE_TYPE_DATASOURCE || type === VARIABLE_TYPE_QUERY) + ) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type(`${dataSource}{enter}`); + }); + } + + if (constantValue && type === VARIABLE_TYPE_CONSTANT) { + e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInput().type(constantValue); + } + + if (type === VARIABLE_TYPE_QUERY) { + if (query) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().type(query); + } + + if (regex) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput().type(regex); + } + } + + // Avoid flakiness + e2e().focused().blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('exist') + .within((previewOfValues) => { + if (type === VARIABLE_TYPE_CONSTANT) { + expect(previewOfValues.text()).equals(constantValue); + } + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + + return fullConfig; +}; + +const addVariables = (configs: PartialAddVariableConfig[]): AddVariableConfig[] => { + if (configs.length > 0) { + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click(); + } + + return configs.map((config, i) => addVariable(config, i === 0)); +}; diff --git a/packages/grafana-e2e/src/flows/addDataSource.ts b/packages/grafana-e2e/src/flows/addDataSource.ts new file mode 100644 index 0000000..5b15464 --- /dev/null +++ b/packages/grafana-e2e/src/flows/addDataSource.ts @@ -0,0 +1,115 @@ +import { DeleteDataSourceConfig } from './deleteDataSource'; +import { e2e } from '../index'; +import { fromBaseUrl, getDataSourceId } from '../support/url'; +import { v4 as uuidv4 } from 'uuid'; + +export interface AddDataSourceConfig { + basicAuth: boolean; + basicAuthPassword: string; + basicAuthUser: string; + checkHealth: boolean; + expectedAlertMessage: string | RegExp; + form: () => void; + name: string; + skipTlsVerify: boolean; + type: string; + timeout?: number; +} + +// @todo this actually returns type `Cypress.Chainable` +export const addDataSource = (config?: Partial) => { + const fullConfig: AddDataSourceConfig = { + basicAuth: false, + basicAuthPassword: '', + basicAuthUser: '', + checkHealth: false, + expectedAlertMessage: 'Data source is working', + form: () => {}, + name: `e2e-${uuidv4()}`, + skipTlsVerify: false, + type: 'TestData DB', + ...config, + }; + + const { + basicAuth, + basicAuthPassword, + basicAuthUser, + checkHealth, + expectedAlertMessage, + form, + name, + skipTlsVerify, + type, + timeout, + } = fullConfig; + + e2e().logToConsole('Adding data source with name:', name); + e2e.pages.AddDataSource.visit(); + e2e.pages.AddDataSource.dataSourcePlugins(type) + .scrollIntoView() + .should('be.visible') // prevents flakiness + .click(); + + e2e.pages.DataSource.name().clear(); + e2e.pages.DataSource.name().type(name); + + if (basicAuth) { + e2e().contains('label', 'Basic auth').scrollIntoView().click(); + e2e() + .contains('.gf-form-group', 'Basic Auth Details') + .should('be.visible') + .scrollIntoView() + .within(() => { + if (basicAuthUser) { + e2e().get('[placeholder=user]').type(basicAuthUser); + } + if (basicAuthPassword) { + e2e().get('[placeholder=Password]').type(basicAuthPassword); + } + }); + } + + if (skipTlsVerify) { + e2e().contains('label', 'Skip TLS Verify').scrollIntoView().click(); + } + + form(); + + e2e.pages.DataSource.saveAndTest().click(); + + // use the timeout passed in if it exists, otherwise, continue to use the default + e2e.pages.DataSource.alert() + .should('exist') + .contains(expectedAlertMessage, { + timeout: timeout ?? e2e.config().defaultCommandTimeout, + }); + e2e().logToConsole('Added data source with name:', name); + + return e2e() + .url() + .then((url: string) => { + const id = getDataSourceId(url); + + e2e.getScenarioContext().then(({ addedDataSources }: any) => { + e2e.setScenarioContext({ + addedDataSources: [...addedDataSources, { id, name } as DeleteDataSourceConfig], + }); + }); + + if (checkHealth) { + const healthUrl = fromBaseUrl(`/api/datasources/${id}/health`); + e2e().logToConsole(`Fetching ${healthUrl}`); + e2e().request(healthUrl).its('body').should('have.property', 'status').and('eq', 'OK'); + } + + // @todo remove `wrap` when possible + return e2e().wrap( + { + config: fullConfig, + id, + }, + { log: false } + ); + }); +}; diff --git a/packages/grafana-e2e/src/flows/addPanel.ts b/packages/grafana-e2e/src/flows/addPanel.ts new file mode 100644 index 0000000..9e1907f --- /dev/null +++ b/packages/grafana-e2e/src/flows/addPanel.ts @@ -0,0 +1,14 @@ +import { configurePanel, PartialAddPanelConfig } from './configurePanel'; +import { getScenarioContext } from '../support/scenarioContext'; +import { v4 as uuidv4 } from 'uuid'; + +export const addPanel = (config?: Partial) => + getScenarioContext().then(({ lastAddedDataSource }: any) => + configurePanel({ + dataSourceName: lastAddedDataSource, + panelTitle: `e2e-${uuidv4()}`, + ...config, + isEdit: false, + isExplore: false, + }) + ); diff --git a/packages/grafana-e2e/src/flows/assertSuccessNotification.ts b/packages/grafana-e2e/src/flows/assertSuccessNotification.ts new file mode 100644 index 0000000..c86f4cc --- /dev/null +++ b/packages/grafana-e2e/src/flows/assertSuccessNotification.ts @@ -0,0 +1,5 @@ +import { e2e } from '../index'; + +export const assertSuccessNotification = () => { + e2e().get('[aria-label^="Alert success"]').should('exist'); +}; diff --git a/packages/grafana-e2e/src/flows/configurePanel.ts b/packages/grafana-e2e/src/flows/configurePanel.ts new file mode 100644 index 0000000..3f9304a --- /dev/null +++ b/packages/grafana-e2e/src/flows/configurePanel.ts @@ -0,0 +1,303 @@ +import { e2e } from '../index'; +import { getLocalStorage, requireLocalStorage } from '../support/localStorage'; +import { getScenarioContext } from '../support/scenarioContext'; +import { selectOption } from './selectOption'; +import { setDashboardTimeRange } from './setDashboardTimeRange'; +import { setTimeRange, TimeRangeConfig } from './setTimeRange'; + +interface AddPanelOverrides { + dataSourceName: string; + queriesForm: (config: AddPanelConfig) => void; + panelTitle: string; +} + +interface EditPanelOverrides { + queriesForm?: (config: EditPanelConfig) => void; + panelTitle: string; +} + +interface ConfigurePanelDefault { + chartData: { + method: string; + route: string | RegExp; + }; + dashboardUid: string; + matchScreenshot: boolean; + saveDashboard: boolean; + screenshotName: string; + visitDashboardAtStart: boolean; // @todo remove when possible +} + +interface ConfigurePanelOptional { + dataSourceName?: string; + queriesForm?: (config: ConfigurePanelConfig) => void; + panelTitle?: string; + timeRange?: TimeRangeConfig; + visualizationName?: string; + matchExploreTable?: boolean; +} + +interface ConfigurePanelRequired { + isEdit: boolean; + isExplore: boolean; +} + +export type PartialConfigurePanelConfig = Partial & + ConfigurePanelOptional & + ConfigurePanelRequired; + +export type ConfigurePanelConfig = ConfigurePanelDefault & ConfigurePanelOptional & ConfigurePanelRequired; + +export type PartialAddPanelConfig = PartialConfigurePanelConfig & AddPanelOverrides; +export type AddPanelConfig = ConfigurePanelConfig & AddPanelOverrides; + +export type PartialEditPanelConfig = PartialConfigurePanelConfig & EditPanelOverrides; +export type EditPanelConfig = ConfigurePanelConfig & EditPanelOverrides; + +// @todo this actually returns type `Cypress.Chainable` +export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelConfig | PartialConfigurePanelConfig) => + getScenarioContext().then(({ lastAddedDashboardUid }: any) => { + const fullConfig: AddPanelConfig | EditPanelConfig | ConfigurePanelConfig = { + chartData: { + method: 'POST', + route: '/api/ds/query', + }, + dashboardUid: lastAddedDashboardUid, + matchScreenshot: false, + saveDashboard: true, + screenshotName: 'panel-visualization', + visitDashboardAtStart: true, + ...config, + }; + + const { + chartData, + dashboardUid, + dataSourceName, + isEdit, + isExplore, + matchExploreTable, + matchScreenshot, + panelTitle, + queriesForm, + screenshotName, + timeRange, + visitDashboardAtStart, + visualizationName, + } = fullConfig; + + if (isEdit && isExplore) { + throw new TypeError('Invalid configuration'); + } + + if (isExplore) { + e2e.pages.Explore.visit(); + } else { + if (visitDashboardAtStart) { + e2e.flows.openDashboard({ uid: dashboardUid }); + } + + if (isEdit) { + e2e.components.Panels.Panel.title(panelTitle).click(); + e2e.components.Panels.Panel.headerItems('Edit').click(); + } else { + e2e.components.PageToolbar.item('Add panel').click(); + e2e.pages.AddDashboard.addNewPanel().click(); + } + } + + if (timeRange) { + if (isExplore) { + e2e.pages.Explore.Toolbar.navBar().within(() => setTimeRange(timeRange)); + } else { + setDashboardTimeRange(timeRange); + } + } + + e2e().server(); + + // @todo alias '/**/*.js*' as '@pluginModule' when possible: https://github.com/cypress-io/cypress/issues/1296 + + e2e().route(chartData.method, chartData.route).as('chartData'); + + if (dataSourceName) { + selectOption({ + container: e2e.components.DataSourcePicker.container(), + optionText: dataSourceName, + }); + } + + // @todo instead wait for '@pluginModule' if not already loaded + e2e().wait(2000); + + if (!isExplore) { + if (!isEdit) { + // Fields could be covered due to an empty query editor + closeRequestErrors(); + } + + // `panelTitle` is needed to edit the panel, and unlikely to have its value changed at that point + const changeTitle = panelTitle && !isEdit; + + if (changeTitle || visualizationName) { + openOptions(); + + if (changeTitle) { + openOptionsGroup('settings'); + getOptionsGroup('settings') + .find('[value="Panel Title"]') + .scrollIntoView() + .clear() + .type(panelTitle as string); + } + + if (visualizationName) { + openOptionsGroup('type'); + e2e.components.PluginVisualization.item(visualizationName).scrollIntoView().click(); + + // @todo wait for '@pluginModule' if not a core visualization and not already loaded + e2e().wait(2000); + } + + // Consistently closed + closeOptionsGroup('settings'); + closeOptionsGroup('type'); + closeOptions(); + } else { + // Consistently closed + closeOptions(); + } + } + + if (queriesForm) { + queriesForm(fullConfig); + e2e().wait('@chartData'); + + // Wait for a possible complex visualization to render (or something related, as this isn't necessary on the dashboard page) + // Can't assert that its HTML changed because a new query could produce the same results + e2e().wait(1000); + } + + // @todo enable when plugins have this implemented + //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); + //e2e().wait('@chartData'); + //e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content').contains('No data'); + //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); + //e2e().wait('@chartData'); + + if (!isExplore) { + e2e().get('button[title="Apply changes and go back to dashboard"]').click(); + e2e().url().should('include', `/d/${dashboardUid}`); + } + + // Avoid annotations flakiness + e2e.components.RefreshPicker.runButton().should('be.visible').click(); + + e2e().wait('@chartData'); + + // Wait for RxJS + e2e().wait(500); + + if (matchScreenshot) { + let visualization; + + if (isExplore) { + visualization = matchExploreTable ? e2e.pages.Explore.General.table() : e2e.pages.Explore.General.graph(); + } else { + visualization = e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content'); + } + + visualization.scrollIntoView().screenshot(screenshotName); + e2e().compareScreenshots(screenshotName); + } + + // @todo remove `wrap` when possible + return e2e().wrap({ config: fullConfig }, { log: false }); + }); + +// @todo this actually returns type `Cypress.Chainable` +const closeOptions = (): any => + isOptionsOpen().then((isOpen: any) => { + if (isOpen) { + e2e.components.PanelEditor.toggleVizOptions().click(); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +const closeOptionsGroup = (name: string): any => + isOptionsGroupOpen(name).then((isOpen: any) => { + if (isOpen) { + toggleOptionsGroup(name); + } + }); + +const closeRequestErrors = () => { + e2e().wait(1000); // emulate `cy.get()` for nested errors + e2e() + .get('app-notifications-list') + .then(($elm) => { + // Avoid failing when none are found + const selector = '[aria-label="Alert error"]:contains("Failed to call resource")'; + const numErrors = $elm.find(selector).length; + + for (let i = 0; i < numErrors; i++) { + e2e().get(selector).first().find('button').click(); + } + }); +}; + +const getOptionsGroup = (name: string) => e2e().get(`.options-group:has([aria-label="Options group Panel ${name}"])`); + +// @todo this actually returns type `Cypress.Chainable` +const isOptionsGroupOpen = (name: string): any => + requireLocalStorage(`grafana.dashboard.editor.ui.optionGroup[Panel ${name}]`).then(({ defaultToClosed }: any) => { + // @todo remove `wrap` when possible + return e2e().wrap(!defaultToClosed, { log: false }); + }); + +// @todo this actually returns type `Cypress.Chainable` +const isOptionsOpen = (): any => + getLocalStorage('grafana.dashboard.editor.ui').then((data: any) => { + if (data) { + // @todo remove `wrap` when possible + return e2e().wrap(data.isPanelOptionsVisible, { log: false }); + } else { + // @todo remove `wrap` when possible + return e2e().wrap(true, { log: false }); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +const openOptions = (): any => + isOptionsOpen().then((isOpen: any) => { + if (!isOpen) { + e2e.components.PanelEditor.toggleVizOptions().click(); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +const openOptionsGroup = (name: string): any => + isOptionsGroupOpen(name).then((isOpen: any) => { + if (!isOpen) { + toggleOptionsGroup(name); + } + }); + +const toggleOptionsGroup = (name: string) => getOptionsGroup(name).find('.editor-options-group-toggle').click(); + +export const VISUALIZATION_ALERT_LIST = 'Alert list'; +export const VISUALIZATION_BAR_GAUGE = 'Bar gauge'; +export const VISUALIZATION_CLOCK = 'Clock'; +export const VISUALIZATION_DASHBOARD_LIST = 'Dashboard list'; +export const VISUALIZATION_GAUGE = 'Gauge'; +export const VISUALIZATION_GRAPH = 'Graph'; +export const VISUALIZATION_HEAT_MAP = 'Heatmap'; +export const VISUALIZATION_LOGS = 'Logs'; +export const VISUALIZATION_NEWS = 'News'; +export const VISUALIZATION_PIE_CHART = 'Pie Chart'; +export const VISUALIZATION_PLUGIN_LIST = 'Plugin list'; +export const VISUALIZATION_POLYSTAT = 'Polystat'; +export const VISUALIZATION_STAT = 'Stat'; +export const VISUALIZATION_TABLE = 'Table'; +export const VISUALIZATION_TEXT = 'Text'; +export const VISUALIZATION_WORLD_MAP = 'Worldmap Panel'; diff --git a/packages/grafana-e2e/src/flows/deleteDashboard.ts b/packages/grafana-e2e/src/flows/deleteDashboard.ts new file mode 100644 index 0000000..9bb90a6 --- /dev/null +++ b/packages/grafana-e2e/src/flows/deleteDashboard.ts @@ -0,0 +1,47 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +export interface DeleteDashboardConfig { + quick?: boolean; + title: string; + uid: string; +} + +export const deleteDashboard = ({ quick = false, title, uid }: DeleteDashboardConfig) => { + e2e().logToConsole('Deleting dashboard with uid:', uid); + + if (quick) { + quickDelete(uid); + } else { + uiDelete(uid, title); + } + + e2e().logToConsole('Deleted dashboard with uid:', uid); + + e2e.getScenarioContext().then(({ addedDashboards }: any) => { + e2e.setScenarioContext({ + addedDashboards: addedDashboards.filter((dashboard: DeleteDashboardConfig) => { + return dashboard.title !== title && dashboard.uid !== uid; + }), + }); + }); +}; + +const quickDelete = (uid: string) => { + e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${uid}`)); +}; + +const uiDelete = (uid: string, title: string) => { + e2e.pages.Dashboard.visit(uid); + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.pages.Dashboard.Settings.General.deleteDashBoard().click(); + e2e.pages.ConfirmModal.delete().click(); + e2e.flows.assertSuccessNotification(); + + e2e.pages.Dashboards.visit(); + + // @todo replace `e2e.pages.Dashboards.dashboards` with this when argument is empty + e2e() + .get('[aria-label^="Dashboard search item "]') + .each((item) => e2e().wrap(item).should('not.contain', title)); +}; diff --git a/packages/grafana-e2e/src/flows/deleteDataSource.ts b/packages/grafana-e2e/src/flows/deleteDataSource.ts new file mode 100644 index 0000000..f68b38f --- /dev/null +++ b/packages/grafana-e2e/src/flows/deleteDataSource.ts @@ -0,0 +1,46 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +export interface DeleteDataSourceConfig { + id: string; + name: string; + quick?: boolean; +} + +export const deleteDataSource = ({ id, name, quick = false }: DeleteDataSourceConfig) => { + e2e().logToConsole('Deleting data source with name:', name); + + if (quick) { + quickDelete(name); + } else { + uiDelete(name); + } + + e2e().logToConsole('Deleted data source with name:', name); + + e2e.getScenarioContext().then(({ addedDataSources }: any) => { + e2e.setScenarioContext({ + addedDataSources: addedDataSources.filter((dataSource: DeleteDataSourceConfig) => { + return dataSource.id !== id && dataSource.name !== name; + }), + }); + }); +}; + +const quickDelete = (name: string) => { + e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${name}`)); +}; + +const uiDelete = (name: string) => { + e2e.pages.DataSources.visit(); + e2e.pages.DataSources.dataSources(name).click(); + e2e.pages.DataSource.delete().click(); + e2e.pages.ConfirmModal.delete().click(); + + e2e.pages.DataSources.visit(); + + // @todo replace `e2e.pages.DataSources.dataSources` with this when argument is empty + e2e() + .get('[aria-label^="Data source list item "]') + .each((item) => e2e().wrap(item).should('not.contain', name)); +}; diff --git a/packages/grafana-e2e/src/flows/editPanel.ts b/packages/grafana-e2e/src/flows/editPanel.ts new file mode 100644 index 0000000..c003d47 --- /dev/null +++ b/packages/grafana-e2e/src/flows/editPanel.ts @@ -0,0 +1,8 @@ +import { configurePanel, PartialEditPanelConfig } from './configurePanel'; + +export const editPanel = (config: Partial) => + configurePanel({ + ...config, + isEdit: true, + isExplore: false, + }); diff --git a/packages/grafana-e2e/src/flows/explore.ts b/packages/grafana-e2e/src/flows/explore.ts new file mode 100644 index 0000000..cc9dd10 --- /dev/null +++ b/packages/grafana-e2e/src/flows/explore.ts @@ -0,0 +1,19 @@ +import { configurePanel, PartialConfigurePanelConfig } from './configurePanel'; +import { getScenarioContext } from '../support/scenarioContext'; + +export const explore = (config: Partial) => + getScenarioContext().then(({ lastAddedDataSource }: any) => + configurePanel({ + dataSourceName: lastAddedDataSource, + screenshotName: 'explore-graph', + ...config, + isEdit: false, + isExplore: true, + timeRange: { + from: '2020-01-01 00:00:00', + to: '2020-01-01 06:00:00', + zone: 'Coordinated Universal Time', + ...config.timeRange, + }, + }) + ); diff --git a/packages/grafana-e2e/src/flows/index.ts b/packages/grafana-e2e/src/flows/index.ts new file mode 100644 index 0000000..0ba42e7 --- /dev/null +++ b/packages/grafana-e2e/src/flows/index.ts @@ -0,0 +1,33 @@ +export * from './addDashboard'; +export * from './addDataSource'; +export * from './addPanel'; +export * from './assertSuccessNotification'; +export * from './deleteDashboard'; +export * from './deleteDataSource'; +export * from './editPanel'; +export * from './explore'; +export * from './login'; +export * from './openDashboard'; +export * from './openPanelMenuItem'; +export * from './revertAllChanges'; +export * from './saveDashboard'; +export * from './selectOption'; + +export { + VISUALIZATION_ALERT_LIST, + VISUALIZATION_BAR_GAUGE, + VISUALIZATION_CLOCK, + VISUALIZATION_DASHBOARD_LIST, + VISUALIZATION_GAUGE, + VISUALIZATION_GRAPH, + VISUALIZATION_HEAT_MAP, + VISUALIZATION_LOGS, + VISUALIZATION_NEWS, + VISUALIZATION_PIE_CHART, + VISUALIZATION_PLUGIN_LIST, + VISUALIZATION_POLYSTAT, + VISUALIZATION_STAT, + VISUALIZATION_TABLE, + VISUALIZATION_TEXT, + VISUALIZATION_WORLD_MAP, +} from './configurePanel'; diff --git a/packages/grafana-e2e/src/flows/login.ts b/packages/grafana-e2e/src/flows/login.ts new file mode 100644 index 0000000..300d8fe --- /dev/null +++ b/packages/grafana-e2e/src/flows/login.ts @@ -0,0 +1,22 @@ +import { e2e } from '../index'; + +const DEFAULT_USERNAME = 'admin'; +const DEFAULT_PASSWORD = 'admin'; + +export const login = (username = DEFAULT_USERNAME, password = DEFAULT_PASSWORD) => { + e2e().logToConsole('Logging in with username:', username); + e2e.pages.Login.visit(); + e2e.pages.Login.username() + .should('be.visible') // prevents flakiness + .type(username); + e2e.pages.Login.password().type(password); + e2e.pages.Login.submit().click(); + + // Local tests will have insecure credentials + if (password === DEFAULT_PASSWORD) { + e2e.pages.Login.skip().should('be.visible').click(); + } + + e2e().get('.login-page').should('not.exist'); + e2e().logToConsole('Logged in with username:', username); +}; diff --git a/packages/grafana-e2e/src/flows/openDashboard.ts b/packages/grafana-e2e/src/flows/openDashboard.ts new file mode 100644 index 0000000..3bbcec0 --- /dev/null +++ b/packages/grafana-e2e/src/flows/openDashboard.ts @@ -0,0 +1,34 @@ +import { e2e } from '../index'; +import { getScenarioContext } from '../support/scenarioContext'; +import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange'; + +interface OpenDashboardDefault { + uid: string; +} + +interface OpenDashboardOptional { + timeRange?: TimeRangeConfig; +} + +export type PartialOpenDashboardConfig = Partial & OpenDashboardOptional; +export type OpenDashboardConfig = OpenDashboardDefault & OpenDashboardOptional; + +// @todo this actually returns type `Cypress.Chainable` +export const openDashboard = (config?: PartialOpenDashboardConfig) => + getScenarioContext().then(({ lastAddedDashboardUid }: any) => { + const fullConfig: OpenDashboardConfig = { + uid: lastAddedDashboardUid, + ...config, + }; + + const { timeRange, uid } = fullConfig; + + e2e.pages.Dashboard.visit(uid); + + if (timeRange) { + setDashboardTimeRange(timeRange); + } + + // @todo remove `wrap` when possible + return e2e().wrap({ config: fullConfig }, { log: false }); + }); diff --git a/packages/grafana-e2e/src/flows/openPanelMenuItem.ts b/packages/grafana-e2e/src/flows/openPanelMenuItem.ts new file mode 100644 index 0000000..5d767ea --- /dev/null +++ b/packages/grafana-e2e/src/flows/openPanelMenuItem.ts @@ -0,0 +1,12 @@ +import { e2e } from '../index'; + +export enum PanelMenuItems { + Edit = 'Edit', + Inspect = 'Inspect', +} + +export const openPanelMenuItem = (menu: PanelMenuItems, panelTitle = 'Panel Title') => { + e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click(); + + e2e.components.Panels.Panel.headerItems(menu).should('be.visible').click(); +}; diff --git a/packages/grafana-e2e/src/flows/revertAllChanges.ts b/packages/grafana-e2e/src/flows/revertAllChanges.ts new file mode 100644 index 0000000..8fda82d --- /dev/null +++ b/packages/grafana-e2e/src/flows/revertAllChanges.ts @@ -0,0 +1,8 @@ +import { e2e } from '../index'; + +export const revertAllChanges = () => { + e2e.getScenarioContext().then(({ addedDashboards, addedDataSources }: any) => { + addedDashboards.forEach((dashboard: any) => e2e.flows.deleteDashboard({ ...dashboard, quick: true })); + addedDataSources.forEach((dataSource: any) => e2e.flows.deleteDataSource({ ...dataSource, quick: true })); + }); +}; diff --git a/packages/grafana-e2e/src/flows/saveDashboard.ts b/packages/grafana-e2e/src/flows/saveDashboard.ts new file mode 100644 index 0000000..37d5238 --- /dev/null +++ b/packages/grafana-e2e/src/flows/saveDashboard.ts @@ -0,0 +1,9 @@ +import { e2e } from '../index'; + +export const saveDashboard = () => { + e2e.components.PageToolbar.item('Save dashboard').click(); + + e2e.pages.SaveDashboardModal.save().click(); + + e2e.flows.assertSuccessNotification(); +}; diff --git a/packages/grafana-e2e/src/flows/selectOption.ts b/packages/grafana-e2e/src/flows/selectOption.ts new file mode 100644 index 0000000..0a9e505 --- /dev/null +++ b/packages/grafana-e2e/src/flows/selectOption.ts @@ -0,0 +1,39 @@ +import { e2e } from '../index'; + +export interface SelectOptionConfig { + clickToOpen?: boolean; + container: any; + forceClickOption?: boolean; + optionText: string | RegExp; +} + +// @todo this actually returns type `Cypress.Chainable` +export const selectOption = (config: SelectOptionConfig): any => { + const fullConfig: SelectOptionConfig = { + clickToOpen: true, + forceClickOption: false, + ...config, + }; + + const { clickToOpen, container, forceClickOption, optionText } = fullConfig; + + return container.within(() => { + if (clickToOpen) { + e2e().get('[class$="-input-suffix"]').click(); + } + + e2e.components.Select.option() + .filter((_, { textContent }) => { + if (textContent === null) { + return false; + } else if (typeof optionText === 'string') { + return textContent.includes(optionText); + } else { + return optionText.test(textContent); + } + }) + .scrollIntoView() + .click({ force: forceClickOption }); + e2e().root().scrollIntoView(); + }); +}; diff --git a/packages/grafana-e2e/src/flows/setDashboardTimeRange.ts b/packages/grafana-e2e/src/flows/setDashboardTimeRange.ts new file mode 100644 index 0000000..9b04412 --- /dev/null +++ b/packages/grafana-e2e/src/flows/setDashboardTimeRange.ts @@ -0,0 +1,7 @@ +import { e2e } from '../index'; +import { setTimeRange, TimeRangeConfig } from './setTimeRange'; + +export { TimeRangeConfig }; + +export const setDashboardTimeRange = (config: TimeRangeConfig) => + e2e.components.PageToolbar.container().within(() => setTimeRange(config)); diff --git a/packages/grafana-e2e/src/flows/setTimeRange.ts b/packages/grafana-e2e/src/flows/setTimeRange.ts new file mode 100644 index 0000000..ec78123 --- /dev/null +++ b/packages/grafana-e2e/src/flows/setTimeRange.ts @@ -0,0 +1,29 @@ +import { e2e } from '../index'; +import { selectOption } from './selectOption'; + +export interface TimeRangeConfig { + from: string; + to: string; + zone?: string; +} + +export const setTimeRange = ({ from, to, zone }: TimeRangeConfig) => { + e2e().get('[aria-label="TimePicker Open Button"]').click(); + + if (zone) { + e2e().contains('button', 'Change time zone').click(); + + selectOption({ + clickToOpen: false, + container: e2e.components.TimeZonePicker.container(), + optionText: zone, + }); + } + + // For smaller screens + e2e().get('[aria-label="TimePicker absolute time range"]').click(); + + e2e().get('[aria-label="TimePicker from field"]').clear().type(from); + e2e().get('[aria-label="TimePicker to field"]').clear().type(to); + e2e().get('[aria-label="TimePicker submit button"]').click(); +}; diff --git a/packages/grafana-e2e/src/index.ts b/packages/grafana-e2e/src/index.ts new file mode 100644 index 0000000..87a0a6a --- /dev/null +++ b/packages/grafana-e2e/src/index.ts @@ -0,0 +1,28 @@ +/** + * A library for writing end-to-end tests for Grafana and its ecosystem. + * + * @packageDocumentation + */ +import { e2eScenario, ScenarioArguments } from './support/scenario'; +import { getScenarioContext, setScenarioContext } from './support/scenarioContext'; +import { e2eFactory } from './support'; +import { E2ESelectors, Selectors, selectors } from '@grafana/e2e-selectors'; +import * as flows from './flows'; +import * as typings from './typings'; + +const e2eObject = { + env: (args: string) => Cypress.env(args), + config: () => Cypress.config(), + blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob), + imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url), + scenario: (args: ScenarioArguments) => e2eScenario(args), + pages: e2eFactory({ selectors: selectors.pages }), + typings, + components: e2eFactory({ selectors: selectors.components }), + flows, + getScenarioContext, + setScenarioContext, + getSelectors: (selectors: E2ESelectors) => e2eFactory({ selectors }), +}; + +export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject); diff --git a/packages/grafana-e2e/src/support/index.ts b/packages/grafana-e2e/src/support/index.ts new file mode 100644 index 0000000..1ebbc3f --- /dev/null +++ b/packages/grafana-e2e/src/support/index.ts @@ -0,0 +1,4 @@ +export * from './localStorage'; +export * from './scenarioContext'; +export * from './selector'; +export * from './types'; diff --git a/packages/grafana-e2e/src/support/localStorage.ts b/packages/grafana-e2e/src/support/localStorage.ts new file mode 100644 index 0000000..eec9955 --- /dev/null +++ b/packages/grafana-e2e/src/support/localStorage.ts @@ -0,0 +1,23 @@ +import { e2e } from '../index'; + +// @todo this actually returns type `Cypress.Chainable` +const get = (key: string): any => + e2e() + .wrap({ getLocalStorage: () => localStorage.getItem(key) }, { log: false }) + .invoke('getLocalStorage'); + +// @todo this actually returns type `Cypress.Chainable` +export const getLocalStorage = (key: string): any => + get(key).then((value: any) => { + if (value === null) { + return value; + } else { + return JSON.parse(value); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +export const requireLocalStorage = (key: string): any => + get(key) // `getLocalStorage()` would turn 'null' into `null` + .should('not.equal', null) + .then((value: any) => JSON.parse(value as string)); diff --git a/packages/grafana-e2e/src/support/scenario.ts b/packages/grafana-e2e/src/support/scenario.ts new file mode 100644 index 0000000..1a499c8 --- /dev/null +++ b/packages/grafana-e2e/src/support/scenario.ts @@ -0,0 +1,46 @@ +import { e2e } from '../'; + +export interface ScenarioArguments { + describeName: string; + itName: string; + scenario: Function; + skipScenario?: boolean; + addScenarioDataSource?: boolean; + addScenarioDashBoard?: boolean; +} + +export const e2eScenario = ({ + describeName, + itName, + scenario, + skipScenario = false, + addScenarioDataSource = false, + addScenarioDashBoard = false, +}: ScenarioArguments) => { + describe(describeName, () => { + if (skipScenario) { + it.skip(itName, () => scenario()); + } else { + before(() => e2e.flows.login(e2e.env('USERNAME'), e2e.env('PASSWORD'))); + + beforeEach(() => { + Cypress.Cookies.preserveOnce('grafana_session'); + + if (addScenarioDataSource) { + e2e.flows.addDataSource(); + } + if (addScenarioDashBoard) { + e2e.flows.addDashboard(); + } + }); + + afterEach(() => e2e.flows.revertAllChanges()); + after(() => e2e().clearCookies()); + + it(itName, () => scenario()); + + // @todo remove when possible: https://github.com/cypress-io/cypress/issues/2831 + it('temporary', () => {}); + } + }); +}; diff --git a/packages/grafana-e2e/src/support/scenarioContext.ts b/packages/grafana-e2e/src/support/scenarioContext.ts new file mode 100644 index 0000000..eeba377 --- /dev/null +++ b/packages/grafana-e2e/src/support/scenarioContext.ts @@ -0,0 +1,61 @@ +import { e2e } from '../index'; +import { DeleteDashboardConfig } from '../flows/deleteDashboard'; +import { DeleteDataSourceConfig } from '../flows/deleteDataSource'; + +export interface ScenarioContext { + addedDashboards: DeleteDashboardConfig[]; + addedDataSources: DeleteDataSourceConfig[]; + lastAddedDashboard: string; // @todo rename to `lastAddedDashboardTitle` + lastAddedDashboardUid: string; + lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName` + lastAddedDataSourceId: string; + [key: string]: any; +} + +const scenarioContext: ScenarioContext = { + addedDashboards: [], + addedDataSources: [], + get lastAddedDashboard() { + return lastProperty(this.addedDashboards, 'title'); + }, + get lastAddedDashboardUid() { + return lastProperty(this.addedDashboards, 'uid'); + }, + get lastAddedDataSource() { + return lastProperty(this.addedDataSources, 'name'); + }, + get lastAddedDataSourceId() { + return lastProperty(this.addedDataSources, 'id'); + }, +}; + +const lastProperty = ( + items: T[], + key: K +) => items[items.length - 1]?.[key] ?? ''; + +// @todo this actually returns type `Cypress.Chainable` +export const getScenarioContext = (): any => + e2e() + .wrap( + { + getScenarioContext: (): ScenarioContext => ({ ...scenarioContext }), + }, + { log: false } + ) + .invoke({ log: false }, 'getScenarioContext'); + +// @todo this actually returns type `Cypress.Chainable` +export const setScenarioContext = (newContext: Partial): any => + e2e() + .wrap( + { + setScenarioContext: () => { + Object.entries(newContext).forEach(([key, value]) => { + scenarioContext[key] = value; + }); + }, + }, + { log: false } + ) + .invoke({ log: false }, 'setScenarioContext'); diff --git a/packages/grafana-e2e/src/support/selector.ts b/packages/grafana-e2e/src/support/selector.ts new file mode 100644 index 0000000..54aa938 --- /dev/null +++ b/packages/grafana-e2e/src/support/selector.ts @@ -0,0 +1,9 @@ +export interface SelectorApi { + fromAriaLabel: (selector: string) => string; + fromSelector: (selector: string) => string; +} + +export const Selector: SelectorApi = { + fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`, + fromSelector: (selector: string) => selector, +}; diff --git a/packages/grafana-e2e/src/support/types.ts b/packages/grafana-e2e/src/support/types.ts new file mode 100644 index 0000000..0476d31 --- /dev/null +++ b/packages/grafana-e2e/src/support/types.ts @@ -0,0 +1,123 @@ +import { CssSelector, FunctionSelector, Selectors, StringSelector, UrlSelector } from '@grafana/e2e-selectors'; +import { e2e } from '../index'; +import { Selector } from './selector'; +import { fromBaseUrl } from './url'; + +export type VisitFunction = (args?: string) => Cypress.Chainable; +export type E2EVisit = { visit: VisitFunction }; +export type E2EFunction = ((text?: string, options?: CypressOptions) => Cypress.Chainable>) & + E2EFunctionWithOnlyOptions; +export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.Chainable>; + +export type TypeSelectors = S extends StringSelector + ? E2EFunctionWithOnlyOptions + : S extends FunctionSelector + ? E2EFunction + : S extends CssSelector + ? E2EFunction + : S extends UrlSelector + ? E2EVisit & Omit, 'url'> + : S extends Record + ? E2EFunctions + : S; + +export type E2EFunctions = { + [P in keyof S]: TypeSelectors; +}; + +export type E2EObjects = E2EFunctions; + +export type E2EFactoryArgs = { selectors: S }; + +export type CypressOptions = Partial; + +const processSelectors = (e2eObjects: E2EFunctions, selectors: S): E2EFunctions => { + const logOutput = (data: any) => e2e().logToConsole('Retrieving Selector:', data); + const keys = Object.keys(selectors); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + const value = selectors[key]; + + if (key === 'url') { + // @ts-ignore + e2eObjects['visit'] = (args?: string) => { + let parsedUrl = ''; + if (typeof value === 'string') { + parsedUrl = fromBaseUrl(value); + } + + if (typeof value === 'function' && args) { + parsedUrl = fromBaseUrl(value(args)); + } + + e2e().logToConsole('Visiting', parsedUrl); + return e2e().visit(parsedUrl); + }; + + continue; + } + + if (typeof value === 'string') { + // @ts-ignore + e2eObjects[key] = (options?: CypressOptions) => { + logOutput(value); + return e2e().get(Selector.fromAriaLabel(value), options); + }; + + continue; + } + + if (typeof value === 'function') { + // @ts-ignore + e2eObjects[key] = function (textOrOptions?: string | CypressOptions, options?: CypressOptions) { + // the input can only be () + if (arguments.length === 0) { + const selector = value((undefined as unknown) as string); + + logOutput(selector); + return e2e().get(selector); + } + + // the input can be (text) or (options) + if (arguments.length === 1) { + if (typeof textOrOptions === 'string') { + const ariaText = value(textOrOptions); + const selector = Selector.fromAriaLabel(ariaText); + + logOutput(selector); + return e2e().get(selector); + } + const selector = value((undefined as unknown) as string); + + logOutput(selector); + return e2e().get(selector, textOrOptions); + } + + // the input can only be (text, options) + if (arguments.length === 2) { + const ariaText = value(textOrOptions as string); + const selector = Selector.fromAriaLabel(ariaText); + + logOutput(selector); + return e2e().get(selector, options); + } + }; + + continue; + } + + if (typeof value === 'object') { + // @ts-ignore + e2eObjects[key] = processSelectors({}, value); + } + } + + return e2eObjects; +}; + +export const e2eFactory = ({ selectors }: E2EFactoryArgs): E2EObjects => { + const e2eObjects: E2EFunctions = {} as E2EFunctions; + processSelectors(e2eObjects, selectors); + + return { ...e2eObjects }; +}; diff --git a/packages/grafana-e2e/src/support/url.ts b/packages/grafana-e2e/src/support/url.ts new file mode 100644 index 0000000..9d579d7 --- /dev/null +++ b/packages/grafana-e2e/src/support/url.ts @@ -0,0 +1,23 @@ +import { e2e } from '../index'; + +const getBaseUrl = () => e2e.env('BASE_URL') || e2e.config().baseUrl || 'http://localhost:3000'; + +export const fromBaseUrl = (url = '') => new URL(url, getBaseUrl()).href; + +export const getDashboardUid = (url: string): string => { + const matches = new URL(url).pathname.match(/\/d\/([^/]+)/); + if (!matches) { + throw new Error(`Couldn't parse uid from ${url}`); + } else { + return matches[1]; + } +}; + +export const getDataSourceId = (url: string): string => { + const matches = new URL(url).pathname.match(/\/edit\/([^/]+)/); + if (!matches) { + throw new Error(`Couldn't parse id from ${url}`); + } else { + return matches[1]; + } +}; diff --git a/packages/grafana-e2e/src/typings/index.ts b/packages/grafana-e2e/src/typings/index.ts new file mode 100644 index 0000000..d4a4805 --- /dev/null +++ b/packages/grafana-e2e/src/typings/index.ts @@ -0,0 +1 @@ +export { undo } from './undo'; diff --git a/packages/grafana-e2e/src/typings/undo.ts b/packages/grafana-e2e/src/typings/undo.ts new file mode 100644 index 0000000..9ee3c1c --- /dev/null +++ b/packages/grafana-e2e/src/typings/undo.ts @@ -0,0 +1,19 @@ +// https://nodejs.org/api/os.html#os_os_platform +enum Platform { + osx = 'darwin', + windows = 'win32', + linux = 'linux', + aix = 'aix', + freebsd = 'freebsd', + openbsd = 'openbsd', + sunos = 'sunos', +} + +export const undo = () => { + switch (Cypress.platform) { + case Platform.osx: + return '{cmd}z'; + default: + return '{ctrl}z'; + } +}; diff --git a/packages/grafana-e2e/test/cypress/integration/0.cli.ts b/packages/grafana-e2e/test/cypress/integration/0.cli.ts new file mode 100644 index 0000000..52d23a4 --- /dev/null +++ b/packages/grafana-e2e/test/cypress/integration/0.cli.ts @@ -0,0 +1,3 @@ +describe('CLI', () => { + it('compiles this file and runs it', () => {}); +}); diff --git a/packages/grafana-e2e/test/cypress/integration/1.api.ts b/packages/grafana-e2e/test/cypress/integration/1.api.ts new file mode 100644 index 0000000..00e790f --- /dev/null +++ b/packages/grafana-e2e/test/cypress/integration/1.api.ts @@ -0,0 +1,7 @@ +import { e2e } from '../../../dist'; + +describe('API', () => { + it('can be imported', () => { + expect(e2e).to.be.a('function'); + }); +}); diff --git a/packages/grafana-e2e/test/cypress/tsconfig.json b/packages/grafana-e2e/test/cypress/tsconfig.json new file mode 100644 index 0000000..1fa1340 --- /dev/null +++ b/packages/grafana-e2e/test/cypress/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@grafana/toolkit/src/config/tsconfig.plugin.json", + "include": ["**/*.ts"], + "compilerOptions": { + "baseUrl": "../node_modules", + "types": ["cypress", "cypress-file-upload"] + } +} diff --git a/packages/grafana-e2e/tsconfig.build.json b/packages/grafana-e2e/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-e2e/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-e2e/tsconfig.json b/packages/grafana-e2e/tsconfig.json new file mode 100644 index 0000000..7e51e9f --- /dev/null +++ b/packages/grafana-e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types"], + "types": ["cypress"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts", "cypress/support/index.d.ts"] +} diff --git a/packages/grafana-runtime/.eslintrc b/packages/grafana-runtime/.eslintrc new file mode 100644 index 0000000..566825e --- /dev/null +++ b/packages/grafana-runtime/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@grafana/runtime", "@grafana/data/*", "@grafana/ui/*", "@grafana/e2e/*"] }] + } +} diff --git a/packages/grafana-runtime/CHANGELOG.md b/packages/grafana-runtime/CHANGELOG.md new file mode 100644 index 0000000..556d424 --- /dev/null +++ b/packages/grafana-runtime/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-runtime/LICENSE_APACHE2 b/packages/grafana-runtime/LICENSE_APACHE2 new file mode 100644 index 0000000..373dde5 --- /dev/null +++ b/packages/grafana-runtime/LICENSE_APACHE2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/grafana-runtime/README.md b/packages/grafana-runtime/README.md new file mode 100644 index 0000000..c4732cc --- /dev/null +++ b/packages/grafana-runtime/README.md @@ -0,0 +1,5 @@ +# Grafana Runtime library + +> **@grafana/runtime is currently in BETA**. + +This package allows access to grafana services. It requires Grafana to be running already and the functions to be imported as externals. diff --git a/packages/grafana-runtime/api-extractor.json b/packages/grafana-runtime/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-runtime/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-runtime/index.js b/packages/grafana-runtime/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-runtime/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json new file mode 100644 index 0000000..5c5a681 --- /dev/null +++ b/packages/grafana-runtime/package.json @@ -0,0 +1,50 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/runtime", + "version": "8.0.0-beta.1", + "description": "Grafana Runtime Library", + "keywords": [ + "grafana", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-runtime" + }, + "main": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=runtime", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@grafana/data": "8.0.0-beta.1", + "@grafana/e2e-selectors": "8.0.0-beta.1", + "@grafana/ui": "8.0.0-beta.1", + "history": "4.10.1", + "systemjs": "0.20.19", + "systemjs-plugin-css": "0.1.37" + }, + "devDependencies": { + "@grafana/tsconfig": "^1.0.0-rc1", + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/history": "^4.7.8", + "@types/jest": "26.0.15", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/systemjs": "^0.20.6", + "lodash": "4.17.21", + "pretty-format": "25.1.0", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "typescript": "4.2.4" + }, + "types": "src/index.ts" +} diff --git a/packages/grafana-runtime/rollup.config.ts b/packages/grafana-runtime/rollup.config.ts new file mode 100644 index 0000000..e6b7121 --- /dev/null +++ b/packages/grafana-runtime/rollup.config.ts @@ -0,0 +1,34 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + file: `dist/index.${env}.js`, + name: libraryName, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + ], + external: ['lodash', 'react', '@grafana/ui', '@grafana/data', '@grafana/e2e-selectors'], // Use Lodash from grafana + plugins: [ + commonjs({ + include: /node_modules/, + }), + resolve(), + sourceMaps(), + env === 'production' && terser(), + ], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-runtime/src/components/DataSourcePicker.tsx b/packages/grafana-runtime/src/components/DataSourcePicker.tsx new file mode 100644 index 0000000..1146c35 --- /dev/null +++ b/packages/grafana-runtime/src/components/DataSourcePicker.tsx @@ -0,0 +1,174 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Components +import { HorizontalGroup, PluginSignatureBadge, Select } from '@grafana/ui'; +import { DataSourceInstanceSettings, isUnsignedPluginSignature, SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '../services/dataSourceSrv'; + +/** + * Component props description for the {@link DataSourcePicker} + * + * @internal + */ +export interface DataSourcePickerProps { + onChange: (ds: DataSourceInstanceSettings) => void; + current: string | null; + hideTextValue?: boolean; + onBlur?: () => void; + autoFocus?: boolean; + openMenuOnFocus?: boolean; + placeholder?: string; + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + noDefault?: boolean; + width?: number; + filter?: (dataSource: DataSourceInstanceSettings) => boolean; +} + +/** + * Component state description for the {@link DataSourcePicker} + * + * @internal + */ +export interface DataSourcePickerState { + error?: string; +} + +/** + * Component to be able to select a datasource from the list of installed and enabled + * datasources in the current Grafana instance. + * + * @internal + */ +export class DataSourcePicker extends PureComponent { + dataSourceSrv = getDataSourceSrv(); + + static defaultProps: Partial = { + autoFocus: false, + openMenuOnFocus: false, + placeholder: 'Select datasource', + }; + + state: DataSourcePickerState = {}; + + constructor(props: DataSourcePickerProps) { + super(props); + } + + componentDidMount() { + const { current } = this.props; + const dsSettings = this.dataSourceSrv.getInstanceSettings(current); + if (!dsSettings) { + this.setState({ error: 'Could not find data source ' + current }); + } + } + + onChange = (item: SelectableValue) => { + const dsSettings = this.dataSourceSrv.getInstanceSettings(item.value); + + if (dsSettings) { + this.props.onChange(dsSettings); + this.setState({ error: undefined }); + } + }; + + private getCurrentValue(): SelectableValue | undefined { + const { current, hideTextValue, noDefault } = this.props; + + if (!current && noDefault) { + return; + } + + const ds = this.dataSourceSrv.getInstanceSettings(current); + + if (ds) { + return { + label: ds.name.substr(0, 37), + value: ds.name, + imgUrl: ds.meta.info.logos.small, + hideText: hideTextValue, + meta: ds.meta, + }; + } + + return { + label: (current ?? 'no name') + ' - not found', + value: current === null ? undefined : current, + imgUrl: '', + hideText: hideTextValue, + }; + } + + getDataSourceOptions() { + const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter } = this.props; + const options = this.dataSourceSrv + .getList({ + alerting, + tracing, + metrics, + dashboard, + mixed, + variables, + annotations, + pluginId, + filter, + type, + }) + .map((ds) => ({ + value: ds.name, + label: `${ds.name}${ds.isDefault ? ' (default)' : ''}`, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + })); + + return options; + } + + render() { + const { autoFocus, onBlur, openMenuOnFocus, placeholder, width } = this.props; + const { error } = this.state; + const options = this.getDataSourceOptions(); + const value = this.getCurrentValue(); + + return ( +
+ + +``` + +After: + +```jsx +import { Select } from '@grafana/ui'; +... + + ) : ( + +
+ {}} + suffix={ + focusCascade ? ( + + ) : ( + + ) + } + /> +
+
+ )} +
+ ); + } +} diff --git a/packages/grafana-ui/src/components/Cascader/optionMappings.ts b/packages/grafana-ui/src/components/Cascader/optionMappings.ts new file mode 100644 index 0000000..294f5dd --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/optionMappings.ts @@ -0,0 +1,29 @@ +import { CascaderOption as RCCascaderOption } from 'rc-cascader/lib/Cascader'; +import { CascaderOption } from './Cascader'; + +type onChangeType = ((values: string[], options: CascaderOption[]) => void) | undefined; + +export const onChangeCascader = (onChanged: onChangeType) => (values: string[], options: RCCascaderOption[]) => { + if (onChanged) { + onChanged(values, fromRCOptions(options)); + } +}; + +type onLoadDataType = ((options: CascaderOption[]) => void) | undefined; + +export const onLoadDataCascader = (onLoadData: onLoadDataType) => (options: RCCascaderOption[]) => { + if (onLoadData) { + onLoadData(fromRCOptions(options)); + } +}; + +const fromRCOptions = (options: RCCascaderOption[]): CascaderOption[] => { + return options.map(fromRCOption); +}; + +const fromRCOption = (option: RCCascaderOption): CascaderOption => { + return { + value: option.value ?? '', + label: (option.label as unknown) as string, + }; +}; diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx new file mode 100644 index 0000000..720098c --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx @@ -0,0 +1,18 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ClickOutsideWrapper } from './ClickOutsideWrapper'; + + + +# ClickOutsideWrapper + +A wrapper component that detects clicks outside of the elements by attaching event listener to `window` or `document` objects. +Useful for components that require an action being triggered when a click outside has occurred, for example closing an overlay or popup. + +# Usage + +```jsx + console.log('Clicked outside')}> +
Container
+
+```` + diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx new file mode 100644 index 0000000..8746e6b --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { ClickOutsideWrapper } from './ClickOutsideWrapper'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './ClickOutsideWrapper.mdx'; + +export default { + title: 'Layout/ClickOutsideWrapper', + component: ClickOutsideWrapper, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + return ( + +
Container
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx new file mode 100644 index 0000000..e976629 --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -0,0 +1,61 @@ +import React, { PureComponent, createRef } from 'react'; + +export interface Props { + /** + * Callback to trigger when clicking outside of current element occurs. + */ + onClick: () => void; + /** + * Runs the 'onClick' function when pressing a key outside of the current element. Defaults to true. + */ + includeButtonPress: boolean; + /** Object to attach the click event listener to. */ + parent: Window | Document; + /** + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. Defaults to false. + */ + useCapture?: boolean; +} + +interface State { + hasEventListener: boolean; +} + +export class ClickOutsideWrapper extends PureComponent { + static defaultProps = { + includeButtonPress: true, + parent: window, + useCapture: false, + }; + myRef = createRef(); + state = { + hasEventListener: false, + }; + + componentDidMount() { + this.props.parent.addEventListener('click', this.onOutsideClick, this.props.useCapture); + if (this.props.includeButtonPress) { + // Use keyup since keydown already has an event listener on window + this.props.parent.addEventListener('keyup', this.onOutsideClick, this.props.useCapture); + } + } + + componentWillUnmount() { + this.props.parent.removeEventListener('click', this.onOutsideClick, this.props.useCapture); + if (this.props.includeButtonPress) { + this.props.parent.removeEventListener('keyup', this.onOutsideClick, this.props.useCapture); + } + } + + onOutsideClick = (event: any) => { + const domNode = this.myRef.current; + + if (!domNode || !domNode.contains(event.target)) { + this.props.onClick(); + } + }; + + render() { + return
{this.props.children}
; + } +} diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx new file mode 100644 index 0000000..8bed9f4 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx @@ -0,0 +1,21 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ClipboardButton } from './ClipboardButton'; + + + +# ClipboardButton + +A wrapper for [clipboard.js](https://github.com/zenorocha/clipboard.js) library that allows copying text to clipboard. The text to be copied should be provided via `getText` prop. + +# Usage + +```jsx + 'Text to be copied'} + onClipboardCopy={() => console.log('text copied')} +> + Copy to clipboard + +```` + diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx new file mode 100644 index 0000000..9de9564 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { Story, Meta } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { ClipboardButton, Props } from './ClipboardButton'; +import { Input } from '../Forms/Legacy/Input/Input'; +import mdx from './ClipboardButton.mdx'; + +export default { + title: 'Buttons/ClipboardButton', + component: ClipboardButton, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + controls: { + exclude: ['size', 'variant', 'icon', 'className', 'fullWidth'], + }, + }, +} as Meta; + +interface StoryProps extends Partial { + inputText: string; + buttonText: string; +} + +const Wrapper: Story = (args) => { + const [copyMessage, setCopyMessage] = useState(''); + const clipboardCopyMessage = 'Value copied to clipboard'; + return ( +
+
+ args.inputText} + onClipboardCopy={() => setCopyMessage(clipboardCopyMessage)} + > + {args.buttonText} + + {}} /> +
+ {copyMessage} +
+ ); +}; +export const CopyToClipboard = Wrapper.bind({}); +CopyToClipboard.args = { + inputText: 'go run build.go -goos linux -pkg-arch amd64 ${OPT} package-only', + buttonText: 'Copy to clipboard', +}; diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx new file mode 100644 index 0000000..52e8d10 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -0,0 +1,51 @@ +import React, { PureComponent } from 'react'; +import Clipboard from 'clipboard'; +import { Button, ButtonProps } from '../Button'; + +export interface Props extends ButtonProps { + /** A function that returns text to be copied */ + getText(): string; + /** Callback when the text has been successfully copied */ + onClipboardCopy?(e: Clipboard.Event): void; + /** Callback when there was an error copying the text */ + onClipboardError?(e: Clipboard.Event): void; +} + +export class ClipboardButton extends PureComponent { + private clipboard!: Clipboard; + private elem!: HTMLButtonElement; + + setRef = (elem: HTMLButtonElement) => { + this.elem = elem; + }; + + componentDidMount() { + const { getText, onClipboardCopy, onClipboardError } = this.props; + + this.clipboard = new Clipboard(this.elem, { + text: () => getText(), + }); + + this.clipboard.on('success', (e: Clipboard.Event) => { + onClipboardCopy && onClipboardCopy(e); + }); + + this.clipboard.on('error', (e: Clipboard.Event) => { + onClipboardError && onClipboardError(e); + }); + } + + componentWillUnmount() { + this.clipboard.destroy(); + } + + render() { + const { getText, onClipboardCopy, onClipboardError, children, ...buttonProps } = this.props; + + return ( + + ); + } +} diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx new file mode 100644 index 0000000..4cdd689 --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx @@ -0,0 +1,10 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { CollapsableSection } from './CollapsableSection'; + + + +# Collapsable Section +A simple container for enabling collapsing/expanding of content. + + + diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx new file mode 100644 index 0000000..6e02c8c --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CollapsableSection } from './CollapsableSection'; +import mdx from './CollapsableSection.mdx'; + +export default { + title: 'Layout/CollapsableSection', + component: CollapsableSection, + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const simple = () => { + return ( + +
{"Here's some content"}
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx new file mode 100644 index 0000000..1ef09c8 --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -0,0 +1,49 @@ +import React, { FC, ReactNode, useState } from 'react'; +import { css } from '@emotion/css'; +import { useStyles2 } from '../../themes'; +import { Icon } from '..'; +import { GrafanaTheme2 } from '@grafana/data'; + +export interface Props { + label: string; + isOpen: boolean; + children: ReactNode; +} + +export const CollapsableSection: FC = ({ label, isOpen, children }) => { + const [open, toggleOpen] = useState(isOpen); + const styles = useStyles2(collapsableSectionStyles); + const headerStyle = open ? styles.header : styles.headerCollapsed; + const tooltip = `Click to ${open ? 'collapse' : 'expand'}`; + + return ( +
+
toggleOpen(!open)} className={headerStyle} title={tooltip}> + {label} + +
+ {open &&
{children}
} +
+ ); +}; + +const collapsableSectionStyles = (theme: GrafanaTheme2) => { + const header = css({ + display: 'flex', + justifyContent: 'space-between', + fontSize: theme.typography.size.lg, + padding: `${theme.spacing(0.5)} 0`, + cursor: 'pointer', + }); + const headerCollapsed = css(header, { + borderBottom: `1px solid ${theme.colors.border.weak}`, + }); + const icon = css({ + color: theme.colors.text.secondary, + }); + const content = css({ + padding: `${theme.spacing(2)} 0`, + }); + + return { header, headerCollapsed, icon, content }; +}; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.mdx b/packages/grafana-ui/src/components/Collapse/Collapse.mdx new file mode 100644 index 0000000..35bdede --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/Collapse.mdx @@ -0,0 +1,18 @@ +import { Meta, Preview, Props } from '@storybook/addon-docs/blocks'; +import {Collapse} from "./Collapse"; + +# Collapse + +A content area, which can be horizontally collapsed and expanded. Can be used to hide extra information on the page. + +## Usage + +```jsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(!isOpen)}> +

Panel data

+
+``` + + diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.story.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.story.tsx new file mode 100644 index 0000000..b7b00dc --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/Collapse.story.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Collapse, ControlledCollapse } from './Collapse'; +import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import mdx from './Collapse.mdx'; + +export default { + title: 'Layout/Collapse', + component: Collapse, + decorators: [withCenteredStory, withHorizontallyCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + return ( + + {(state, updateValue) => { + return ( + updateValue({ isOpen: !state.isOpen })} + > +

Panel data

+
+ ); + }} +
+ ); +}; + +export const controlled = () => { + return ( + +

Panel data

+
+ ); +}; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx new file mode 100644 index 0000000..727270b --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -0,0 +1,163 @@ +import React, { FunctionComponent, useState } from 'react'; +import { css, cx } from '@emotion/css'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { Icon } from '../Icon/Icon'; +import { GrafanaTheme2 } from '@grafana/data'; + +const getStyles = (theme: GrafanaTheme2) => ({ + collapse: css` + label: collapse; + margin-bottom: ${theme.spacing(1)}; + `, + collapseBody: css` + label: collapse__body; + padding: ${theme.spacing(theme.components.panel.padding)}; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + `, + bodyContentWrapper: css` + label: bodyContentWrapper; + flex: 1; + overflow: hidden; + `, + loader: css` + label: collapse__loader; + height: 2px; + position: relative; + overflow: hidden; + background: none; + margin: ${theme.spacing(0.5)}; + `, + loaderActive: css` + label: collapse__loader_active; + &:after { + content: ' '; + display: block; + width: 25%; + top: 0; + top: -50%; + height: 250%; + position: absolute; + animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms; + animation-iteration-count: 100; + left: -25%; + background: ${theme.colors.primary.main}; + } + @keyframes loader { + from { + left: -25%; + opacity: 0.1; + } + to { + left: 100%; + opacity: 1; + } + } + `, + header: css` + label: collapse__header; + padding: ${theme.spacing(1, 2)}; + display: flex; + cursor: inherit; + transition: all 0.1s linear; + cursor: pointer; + `, + headerCollapsed: css` + label: collapse__header--collapsed; + cursor: pointer; + padding: ${theme.spacing(1, 2)}; + `, + headerButtons: css` + label: collapse__header-buttons; + margin-right: ${theme.spacing(1)}; + margin-top: ${theme.spacing(0.25)}; + font-size: ${theme.typography.size.lg}; + line-height: ${theme.typography.h6.lineHeight}; + display: inherit; + `, + headerButtonsCollapsed: css` + label: collapse__header-buttons--collapsed; + display: none; + `, + headerLabel: css` + label: collapse__header-label; + font-weight: ${theme.typography.fontWeightMedium}; + margin-right: ${theme.spacing(1)}; + font-size: ${theme.typography.size.md}; + `, +}); + +export interface Props { + /** Expand or collapse te content */ + isOpen?: boolean; + /** Element or text for the Collapse header */ + label: React.ReactNode; + /** Indicates loading state of the content */ + loading?: boolean; + /** Toggle collapsed header icon */ + collapsible?: boolean; + /** Callback for the toggle functionality */ + onToggle?: (isOpen: boolean) => void; + /** Additional class name for the root element */ + className?: string; +} + +export const ControlledCollapse: FunctionComponent = ({ isOpen, onToggle, ...otherProps }) => { + const [open, setOpen] = useState(isOpen); + return ( + { + setOpen(!open); + if (onToggle) { + onToggle(!open); + } + }} + /> + ); +}; + +export const Collapse: FunctionComponent = ({ + isOpen, + label, + loading, + collapsible, + onToggle, + className, + children, +}) => { + const style = useStyles2(getStyles); + const onClickToggle = () => { + if (onToggle) { + onToggle(!isOpen); + } + }; + + const panelClass = cx([style.collapse, 'panel-container', className]); + const loaderClass = loading ? cx([style.loader, style.loaderActive]) : cx([style.loader]); + const headerClass = collapsible ? cx([style.header]) : cx([style.headerCollapsed]); + const headerButtonsClass = collapsible ? cx([style.headerButtons]) : cx([style.headerButtonsCollapsed]); + + return ( +
+
+
+ +
+
{label}
+
+ {isOpen && ( +
+
+
{children}
+
+ )} +
+ ); +}; + +Collapse.displayName = 'Collapse'; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx new file mode 100644 index 0000000..684dd41 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import tinycolor from 'tinycolor2'; +import { debounce } from 'lodash'; + +import { ColorPickerProps } from './ColorPickerPopover'; +import { Input } from '../Input/Input'; +import { useStyles2 } from '../../themes'; +import { GrafanaTheme2 } from '@grafana/data'; +import { cx, css } from '@emotion/css'; + +interface ColorInputState { + previousColor: string; + value: string; +} + +interface ColorInputProps extends ColorPickerProps { + style?: React.CSSProperties; + className?: string; +} + +class ColorInput extends React.PureComponent { + constructor(props: ColorInputProps) { + super(props); + this.state = { + previousColor: props.color, + value: props.color, + }; + + this.updateColor = debounce(this.updateColor, 100); + } + + static getDerivedStateFromProps(props: ColorPickerProps, state: ColorInputState) { + const newColor = tinycolor(props.color); + if (newColor.isValid() && props.color !== state.previousColor) { + return { + ...state, + previousColor: props.color, + value: newColor.toString(), + }; + } + + return state; + } + updateColor = (color: string) => { + this.props.onChange(color); + }; + + onChange = (event: React.SyntheticEvent) => { + const newColor = tinycolor(event.currentTarget.value); + + this.setState({ + value: event.currentTarget.value, + }); + + if (newColor.isValid()) { + this.updateColor(newColor.toString()); + } + }; + + onBlur = () => { + const newColor = tinycolor(this.state.value); + + if (!newColor.isValid()) { + this.setState({ + value: this.props.color, + }); + } + }; + + render() { + const { value } = this.state; + return ( + } + /> + ); + } +} + +export default ColorInput; + +interface ColorPreviewProps { + color: string; +} + +const ColorPreview = ({ color }: ColorPreviewProps) => { + const styles = useStyles2(getColorPreviewStyles); + + return ( +
+ ); +}; + +const getColorPreviewStyles = (theme: GrafanaTheme2) => css` + height: 100%; + width: ${theme.spacing.gridSize * 4}px; + border-radius: ${theme.shape.borderRadius()} 0 0 ${theme.shape.borderRadius()}; + border: 1px solid ${theme.colors.border.medium}; +`; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx new file mode 100644 index 0000000..6f1b631 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx @@ -0,0 +1,14 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ColorPicker } from './ColorPicker'; + + + +# ColorPicker + +The `ColorPicker` component group consists of several building blocks that are combined in Grafana to create the `ColorPicker`: popover, pickers and palettes. There are different combinations of these building blocks depending on where the `ColorPicker` is used in Grafana. + +The `Popover` is a tabbed view where you can switch between `Palettes`. The `NamedColorsPalette` shows an arrangement of preset colors, while the `SpectrumPalette` is an unlimited HSB color picker. The preset colors are optimized to work well with both light and dark theme. `Popover` is triggered, for example, by the series legend of graphs, or by `Pickers`. + +The `Pickers` are single circular color fields that show the currently picked color. On click, they open the `Popover`. + + diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx new file mode 100644 index 0000000..2838fdb --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Meta, Story } from '@storybook/react'; +import { SeriesColorPicker, ColorPicker } from '@grafana/ui'; +import { action } from '@storybook/addon-actions'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; +import { ColorPickerProps } from './ColorPickerPopover'; +import mdx from './ColorPicker.mdx'; + +export default { + title: 'Pickers and Editors/ColorPicker', + component: ColorPicker, + subcomponents: { SeriesColorPicker }, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + controls: { + exclude: ['color', 'onChange', 'onColorChange'], + }, + }, + args: { + enableNamedColors: false, + }, +} as Meta; + +export const Basic: Story = ({ enableNamedColors }) => { + return ( + + {(selectedColor, updateSelectedColor) => { + return renderComponentWithTheme(ColorPicker, { + enableNamedColors, + color: selectedColor, + onChange: (color: any) => { + action('Color changed')(color); + updateSelectedColor(color); + }, + }); + }} + + ); +}; + +export const SeriesPicker: Story = ({ enableNamedColors }) => { + return ( + + {(selectedColor, updateSelectedColor) => { + return ( + {}} + color={selectedColor} + onChange={(color) => updateSelectedColor(color)} + > + {({ ref, showColorPicker, hideColorPicker }) => ( +
+ Open color picker +
+ )} +
+ ); + }} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx new file mode 100644 index 0000000..00a703f --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { ColorPicker } from './ColorPicker'; +import { ColorSwatch } from './ColorSwatch'; + +describe('ColorPicker', () => { + it('renders ColorPickerTrigger component by default', () => { + expect( + renderer.create( {}} />).root.findByType(ColorSwatch) + ).toBeTruthy(); + }); + + it('renders custom trigger when supplied', () => { + const div = renderer + .create( + {}}> + {() =>
Custom trigger
} +
+ ) + .root.findByType('div'); + expect(div.children[0]).toBe('Custom trigger'); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000..e91750d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,120 @@ +import React, { Component, createRef } from 'react'; +import { PopoverController } from '../Tooltip/PopoverController'; +import { Popover } from '../Tooltip/Popover'; +import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover'; +import { GrafanaTheme2 } from '@grafana/data'; +import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; + +import { css } from '@emotion/css'; +import { withTheme2, stylesFactory } from '../../themes'; +import { ColorSwatch } from './ColorSwatch'; + +/** + * If you need custom trigger for the color picker you can do that with a render prop pattern and supply a function + * as a child. You will get show/hide function which you can map to desired interaction (like onClick or onMouseLeave) + * and a ref which needs to be passed to an HTMLElement for correct positioning. If you want to use class or functional + * component as a custom trigger you will need to forward the reference to first HTMLElement child. + */ +type ColorPickerTriggerRenderer = (props: { + // This should be a React.RefObject but due to how object refs are defined you cannot downcast from that + // to a specific type like React.RefObject even though it would be fine in runtime. + ref: React.RefObject; + showColorPicker: () => void; + hideColorPicker: () => void; +}) => React.ReactNode; + +export const colorPickerFactory = ( + popover: React.ComponentType, + displayName = 'ColorPicker' +) => { + return class ColorPicker extends Component { + static displayName = displayName; + pickerTriggerRef = createRef(); + + onColorChange = (color: string) => { + const { onColorChange, onChange } = this.props; + const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler; + + return changeHandler(color); + }; + + render() { + const { theme, children } = this.props; + const styles = getStyles(theme); + const popoverElement = React.createElement(popover, { + ...{ ...this.props, children: null }, + onChange: this.onColorChange, + }); + + return ( + + {(showPopper, hidePopper, popperProps) => { + return ( + <> + {this.pickerTriggerRef.current && ( + + )} + + {children ? ( + // Children have a bit weird type due to intersection used in the definition so we need to cast here, + // but the definition is correct and should not allow to pass a children that does not conform to + // ColorPickerTriggerRenderer type. + (children as ColorPickerTriggerRenderer)({ + ref: this.pickerTriggerRef, + showColorPicker: showPopper, + hideColorPicker: hidePopper, + }) + ) : ( + + )} + + ); + }} + + ); + } + }; +}; + +export const ColorPicker = withTheme2(colorPickerFactory(ColorPickerPopover, 'ColorPicker')); +export const SeriesColorPicker = withTheme2(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker')); + +const getStyles = stylesFactory((theme: GrafanaTheme2) => { + return { + colorPicker: css` + position: absolute; + z-index: ${theme.zIndex.tooltip}; + color: ${theme.colors.text.primary}; + max-width: 400px; + font-size: ${theme.typography.size.sm}; + // !important because these styles are also provided to popper via .popper classes from Tooltip component + // hope to get rid of those soon + padding: 15px !important; + & [data-placement^='top'] { + padding-left: 0 !important; + padding-right: 0 !important; + } + & [data-placement^='bottom'] { + padding-left: 0 !important; + padding-right: 0 !important; + } + & [data-placement^='left'] { + padding-top: 0 !important; + } + & [data-placement^='right'] { + padding-top: 0 !important; + } + `, + }; +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx new file mode 100644 index 0000000..5b04d0d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx @@ -0,0 +1,30 @@ +import { ColorPickerPopover } from './ColorPickerPopover'; + +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; + +export default { + title: 'Pickers and Editors/ColorPicker/Popovers', + component: ColorPickerPopover, + subcomponents: { SeriesColorPickerPopover }, + decorators: [withCenteredStory], +}; + +export const basic = () => { + return renderComponentWithTheme(ColorPickerPopover, { + color: '#BC67E6', + onChange: (color: any) => { + console.log(color); + }, + }); +}; + +export const seriesColorPickerPopover = () => { + return renderComponentWithTheme(SeriesColorPickerPopover, { + color: '#BC67E6', + onChange: (color: any) => { + console.log(color); + }, + }); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx new file mode 100644 index 0000000..1ce5b2f --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { ColorPickerPopover } from './ColorPickerPopover'; +import { ColorSwatch } from './ColorSwatch'; +import { createTheme, getColorForTheme } from '@grafana/data'; + +describe('ColorPickerPopover', () => { + const theme = createTheme(); + + describe('rendering', () => { + it('should render provided color as selected if color provided by name', () => { + const wrapper = mount( {}} />); + const selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere((node) => node.prop('isSelected') === false); + + expect(selectedSwatch.length).toBe(1); + expect(notSelectedSwatches.length).toBe(31); + expect(selectedSwatch.prop('isSelected')).toBe(true); + }); + }); + + describe('named colors support', () => { + const onChangeSpy = jest.fn(); + let wrapper: ReactWrapper; + + afterEach(() => { + wrapper.unmount(); + onChangeSpy.mockClear(); + }); + + it('should pass hex color value to onChange prop by default', () => { + wrapper = mount(); + + const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + basicBlueSwatch.simulate('click'); + + expect(onChangeSpy).toBeCalledTimes(1); + expect(onChangeSpy).toBeCalledWith(getColorForTheme('green', theme.v1)); + }); + + it('should pass color name to onChange prop when named colors enabled', () => { + wrapper = mount(); + + const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + basicBlueSwatch.simulate('click'); + + expect(onChangeSpy).toBeCalledTimes(1); + expect(onChangeSpy).toBeCalledWith('green'); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx new file mode 100644 index 0000000..4e1abf4 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { NamedColorsPalette } from './NamedColorsPalette'; +import { PopoverContentProps } from '../Tooltip/Tooltip'; +import SpectrumPalette from './SpectrumPalette'; +import { Themeable2 } from '../../types/theme'; +import { warnAboutColorPickerPropsDeprecation } from './warnAboutColorPickerPropsDeprecation'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { stylesFactory, withTheme2 } from '../../themes'; + +export type ColorPickerChangeHandler = (color: string) => void; + +export interface ColorPickerProps extends Themeable2 { + color: string; + onChange: ColorPickerChangeHandler; + + /** + * @deprecated Use onChange instead + */ + onColorChange?: ColorPickerChangeHandler; + enableNamedColors?: boolean; +} + +export interface Props extends ColorPickerProps, PopoverContentProps { + customPickers?: T; +} + +type PickerType = 'palette' | 'spectrum'; + +export interface CustomPickersDescriptor { + [key: string]: { + tabComponent: React.ComponentType; + name: string; + }; +} + +interface State { + activePicker: PickerType | keyof T; +} + +class UnThemedColorPickerPopover extends React.Component, State> { + constructor(props: Props) { + super(props); + this.state = { + activePicker: 'palette', + }; + warnAboutColorPickerPropsDeprecation('ColorPickerPopover', props); + } + + getTabClassName = (tabName: PickerType | keyof T) => { + const { activePicker } = this.state; + return `ColorPickerPopover__tab ${activePicker === tabName && 'ColorPickerPopover__tab--active'}`; + }; + + handleChange = (color: any) => { + const { onColorChange, onChange, enableNamedColors, theme } = this.props; + const changeHandler = onColorChange || onChange; + + if (enableNamedColors) { + return changeHandler(color); + } + changeHandler(theme.visualization.getColorByName(color)); + }; + + onTabChange = (tab: PickerType | keyof T) => { + return () => this.setState({ activePicker: tab }); + }; + + renderPicker = () => { + const { activePicker } = this.state; + const { color } = this.props; + + switch (activePicker) { + case 'spectrum': + return ; + case 'palette': + return ; + default: + return this.renderCustomPicker(activePicker); + } + }; + + renderCustomPicker = (tabKey: keyof T) => { + const { customPickers, color, theme } = this.props; + if (!customPickers) { + return null; + } + + return React.createElement(customPickers[tabKey].tabComponent, { + color, + theme, + onChange: this.handleChange, + }); + }; + + renderCustomPickerTabs = () => { + const { customPickers } = this.props; + + if (!customPickers) { + return null; + } + + return ( + <> + {Object.keys(customPickers).map((key) => { + return ( +
+ {customPickers[key].name} +
+ ); + })} + + ); + }; + + render() { + const { theme } = this.props; + const styles = getStyles(theme); + return ( +
+
+
+ Colors +
+
+ Custom +
+ {this.renderCustomPickerTabs()} +
+
{this.renderPicker()}
+
+ ); + } +} + +export const ColorPickerPopover = withTheme2(UnThemedColorPickerPopover); +ColorPickerPopover.displayName = 'ColorPickerPopover'; + +const getStyles = stylesFactory((theme: GrafanaTheme2) => { + return { + colorPickerPopover: css` + border-radius: ${theme.shape.borderRadius()}; + box-shadow: ${theme.shadows.z3}; + background: ${theme.colors.background.primary}; + + .ColorPickerPopover__tab { + width: 50%; + text-align: center; + padding: ${theme.spacing(1, 0)}; + background: ${theme.colors.background.secondary}; + color: ${theme.colors.text.secondary}; + cursor: pointer; + } + + .ColorPickerPopover__tab--active { + color: ${theme.colors.text.primary}; + font-weight: ${theme.typography.fontWeightMedium}; + background: ${theme.colors.background.primary}; + } + `, + colorPickerPopoverContent: css` + width: 336px; + font-size: ${theme.typography.bodySmall.fontSize}; + min-height: 184px; + padding: ${theme.spacing(2)}; + display: flex; + align-items: center; + justify-content: center; + `, + colorPickerPopoverTabs: css` + display: flex; + width: 100%; + border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0; + overflow: hidden; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx new file mode 100644 index 0000000..c9b691f --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx @@ -0,0 +1,60 @@ +import React, { CSSProperties } from 'react'; +import tinycolor from 'tinycolor2'; +import { useTheme2 } from '../../themes/ThemeContext'; + +/** @internal */ +export enum ColorSwatchVariant { + Small = 'small', + Large = 'large', +} + +/** @internal */ +export interface Props extends React.DOMAttributes { + color: string; + label?: string; + variant?: ColorSwatchVariant; + isSelected?: boolean; +} + +/** @internal */ +export const ColorSwatch = React.forwardRef( + ({ color, label, variant = ColorSwatchVariant.Small, isSelected, ...otherProps }, ref) => { + const theme = useTheme2(); + const tc = tinycolor(color); + const isSmall = variant === ColorSwatchVariant.Small; + const hasLabel = !!label; + const swatchSize = isSmall ? '16px' : '32px'; + + const swatchStyles: CSSProperties = { + width: swatchSize, + height: swatchSize, + borderRadius: '50%', + background: `${color}`, + marginRight: hasLabel ? '8px' : '0px', + boxShadow: isSelected + ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}` + : 'none', + }; + + if (tc.getAlpha() < 0.1) { + swatchStyles.border = `2px solid ${theme.colors.border.medium}`; + } + + return ( +
+
+ {hasLabel && {label}} +
+ ); + } +); + +ColorSwatch.displayName = 'ColorSwatch'; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx new file mode 100644 index 0000000..0e1d77b --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx @@ -0,0 +1,58 @@ +import React, { FunctionComponent } from 'react'; +import { ThemeVizHue } from '@grafana/data'; +import { Color } from 'csstype'; +import { ColorSwatch, ColorSwatchVariant } from './ColorSwatch'; +import { upperFirst } from 'lodash'; + +interface NamedColorsGroupProps { + hue: ThemeVizHue; + selectedColor?: Color; + onColorSelect: (colorName: string) => void; + key?: string; +} + +const NamedColorsGroup: FunctionComponent = ({ + hue, + selectedColor, + onColorSelect, + ...otherProps +}) => { + const primaryShade = hue.shades.find((shade) => shade.primary)!; + + return ( +
+ {primaryShade && ( + onColorSelect(primaryShade.name)} + /> + )} +
+ {hue.shades.map( + (shade) => + !shade.primary && ( +
+ onColorSelect(shade.name)} + /> +
+ ) + )} +
+
+ ); +}; + +export default NamedColorsGroup; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx new file mode 100644 index 0000000..75347a5 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { NamedColorsPalette, NamedColorsPaletteProps } from './NamedColorsPalette'; +import { Meta, Story } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './ColorPicker.mdx'; + +export default { + title: 'Pickers and Editors/ColorPicker/Palettes/NamedColorsPalette', + component: NamedColorsPalette, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + controls: { + exclude: ['theme', 'color'], + }, + }, + argTypes: { + selectedColor: { control: { type: 'select', options: ['green', 'red', 'light-blue', 'yellow'] } }, + }, +} as Meta; + +interface StoryProps extends Partial { + selectedColor: string; +} + +export const NamedColors: Story = ({ selectedColor }) => { + const [color, setColor] = useState('green'); + return ; +}; + +NamedColors.args = { + color: 'green', +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx new file mode 100644 index 0000000..023dad1 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { NamedColorsPalette } from './NamedColorsPalette'; +import { createTheme } from '@grafana/data'; +import { ColorSwatch } from './ColorSwatch'; + +describe('NamedColorsPalette', () => { + const theme = createTheme(); + const greenHue = theme.visualization.hues.find((x) => x.name === 'green')!; + const selectedShade = greenHue.shades[2]; + + describe('theme support for named colors', () => { + let wrapper: ReactWrapper, selectedSwatch; + + afterEach(() => { + wrapper.unmount(); + }); + + it('should render provided color variant specific for theme', () => { + wrapper = mount( {}} />); + selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === selectedShade.name); + expect(selectedSwatch.prop('color')).toBe(selectedShade.color); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx new file mode 100644 index 0000000..d05959f --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import NamedColorsGroup from './NamedColorsGroup'; +import { VerticalGroup } from '../Layout/Layout'; +import { ColorSwatch } from './ColorSwatch'; +import { useTheme2 } from '../../themes/ThemeContext'; + +export interface NamedColorsPaletteProps { + color?: string; + onChange: (colorName: string) => void; +} + +export const NamedColorsPalette = ({ color, onChange }: NamedColorsPaletteProps) => { + const theme = useTheme2(); + + const swatches: JSX.Element[] = []; + for (const hue of theme.visualization.hues) { + swatches.push(); + } + + return ( + +
+ {swatches} +
+ onChange('transparent')} + /> + onChange('text')} + /> +
+ + ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx new file mode 100644 index 0000000..37c806c --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx @@ -0,0 +1,104 @@ +import React, { FunctionComponent } from 'react'; + +import { ColorPickerPopover, ColorPickerProps } from './ColorPickerPopover'; +import { PopoverContentProps } from '../Tooltip/Tooltip'; +import { Switch } from '../Forms/Legacy/Switch/Switch'; +import { css } from '@emotion/css'; +import { withTheme2, useStyles } from '../../themes'; +import { Button } from '../Button'; + +export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopoverContentProps { + yaxis?: number; + onToggleAxis?: () => void; +} + +export const SeriesColorPickerPopover: FunctionComponent = (props) => { + const styles = useStyles(getStyles); + const { yaxis, onToggleAxis, color, ...colorPickerProps } = props; + + const customPickers = onToggleAxis + ? { + yaxis: { + name: 'Y-Axis', + tabComponent() { + return ( + { + if (onToggleAxis) { + onToggleAxis(); + } + }} + /> + ); + }, + }, + } + : undefined; + return ; +}; + +interface AxisSelectorProps { + yaxis: number; + onToggleAxis?: () => void; +} + +interface AxisSelectorState { + yaxis: number; +} + +export class AxisSelector extends React.PureComponent { + constructor(props: AxisSelectorProps) { + super(props); + this.state = { + yaxis: this.props.yaxis, + }; + this.onToggleAxis = this.onToggleAxis.bind(this); + } + + onToggleAxis() { + this.setState({ + yaxis: this.state.yaxis === 2 ? 1 : 2, + }); + + if (this.props.onToggleAxis) { + this.props.onToggleAxis(); + } + } + + render() { + const leftButtonVariant = this.state.yaxis === 1 ? 'primary' : 'secondary'; + const rightButtonVariant = this.state.yaxis === 2 ? 'primary' : 'secondary'; + + return ( +
+ + + +
+ ); + } +} + +// This component is to enable SeriesColorPickerPopover usage via series-color-picker-popover directive +export const SeriesColorPickerPopoverWithTheme = withTheme2(SeriesColorPickerPopover); + +const getStyles = () => { + return { + colorPickerAxisSwitch: css` + width: 100%; + `, + colorPickerAxisSwitchLabel: css` + display: flex; + flex-grow: 1; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx new file mode 100644 index 0000000..dc6713e --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import SpectrumPalette from './SpectrumPalette'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; +import mdx from './ColorPicker.mdx'; + +export default { + title: 'Pickers and Editors/ColorPicker/Palettes/SpectrumPalette', + component: SpectrumPalette, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const simple = () => { + return ( + + {(selectedColor, updateSelectedColor) => { + return renderComponentWithTheme(SpectrumPalette, { color: selectedColor, onChange: updateSelectedColor }); + }} + + ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx new file mode 100644 index 0000000..1c305c3 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx @@ -0,0 +1,69 @@ +import React, { useMemo, useState } from 'react'; + +import { RgbaStringColorPicker } from 'react-colorful'; +import tinycolor from 'tinycolor2'; +import ColorInput from './ColorInput'; +import { GrafanaTheme, getColorForTheme } from '@grafana/data'; +import { css, cx } from '@emotion/css'; +import { useStyles, useTheme2 } from '../../themes'; +import { useThrottleFn } from 'react-use'; + +export interface SpectrumPaletteProps { + color: string; + onChange: (color: string) => void; +} + +const SpectrumPalette: React.FunctionComponent = ({ color, onChange }) => { + const [currentColor, setColor] = useState(color); + useThrottleFn(onChange, 500, [currentColor]); + + const theme = useTheme2(); + const styles = useStyles(getStyles); + + const rgbaString = useMemo(() => { + return currentColor.startsWith('rgba') + ? currentColor + : tinycolor(getColorForTheme(currentColor, theme.v1)).toRgbString(); + }, [currentColor, theme]); + + return ( +
+ + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + flex-grow: 1; + `, + root: css` + &.react-colorful { + width: auto; + } + + .react-colorful { + &__saturation { + border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0; + } + &__alpha { + border-radius: 0 0 ${theme.border.radius.sm} ${theme.border.radius.sm}; + } + &__alpha, + &__hue { + height: ${theme.spacing.md}; + position: relative; + } + &__pointer { + height: ${theme.spacing.md}; + width: ${theme.spacing.md}; + } + } + `, + colorInput: css` + margin-top: ${theme.spacing.md}; + `, +}); + +export default SpectrumPalette; diff --git a/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts b/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts new file mode 100644 index 0000000..145cf5d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts @@ -0,0 +1,9 @@ +import { deprecationWarning } from '@grafana/data'; +import { ColorPickerProps } from './ColorPickerPopover'; + +export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => { + const { onColorChange } = props; + if (onColorChange) { + deprecationWarning(componentName, 'onColorChange', 'onChange'); + } +}; diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx new file mode 100644 index 0000000..04daec4 --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx @@ -0,0 +1,31 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ConfirmButton } from './ConfirmButton'; + + + +# ConfirmButton + +The ConfirmButton is an interactive component that adds a double-confirm option to a clickable action. When clicked, the action is replaced by an inline confirmation with the option to cancel. In Grafana, this is used, for example, for editing values in settings tables. + +## Variants + +There are four variants of the `ConfirmButton`: primary, secondary, destructive, and link. The primary and secondary variants include a primary or secondary `Button` component. The primary and secondary variant should be used to confirm actions like saving or adding data. The destructive variant includes a destructive `Button` component. The destructive variant should be used to double-confirm a deletion or removal of an element. The link variant doesn't include any button and double-confirms as links instead. + +Apart from the button variant, you can also modify the button size and the button text. + +## Usage + +```jsx + { + console.log('Action confirmed!') + }} +> + Click me + +``` + diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx new file mode 100644 index 0000000..3613f7c --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Meta, Story } from '@storybook/react'; +import { ConfirmButton } from '@grafana/ui'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { action } from '@storybook/addon-actions'; +import { Button } from '../Button'; +import { DeleteButton } from './DeleteButton'; +import { Props } from './ConfirmButton'; +import mdx from './ConfirmButton.mdx'; + +export default { + title: 'Buttons/ConfirmButton', + component: ConfirmButton, + decorators: [withCenteredStory], + subcomponents: { DeleteButton }, + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + controls: { + exclude: ['className'], + }, + }, + args: { + buttonText: 'Edit', + confirmText: 'Save', + size: 'md', + confirmVariant: 'primary', + disabled: false, + closeOnConfirm: true, + }, + argTypes: { + confirmVariant: { control: { type: 'select' } }, + size: { control: { type: 'select' } }, + }, +} as Meta; + +interface StoryProps extends Partial { + buttonText: string; +} + +export const Basic: Story = (args) => { + return ( + { + action('Saved')('save!'); + }} + > + {args.buttonText} + + ); +}; + +export const WithCustomButton: Story = (args) => { + return ( + { + action('Saved')('save!'); + }} + > + + + ); +}; + +export const Delete: Story = (args) => { + return ( + { + action('Deleted')('delete!'); + }} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx new file mode 100644 index 0000000..e3117ec --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ConfirmButton } from './ConfirmButton'; +import { mount, ShallowWrapper } from 'enzyme'; +import { Button } from '../Button'; + +describe('ConfirmButton', () => { + let wrapper: any; + let deleted: any; + + beforeAll(() => { + deleted = false; + + function deleteItem() { + deleted = true; + } + + wrapper = mount( + deleteItem()}> + Delete + + ); + }); + + it('should show confirm delete when clicked', () => { + expect(deleted).toBe(false); + wrapper + .find(Button) + .findWhere((n: ShallowWrapper) => { + return n.text() === 'Confirm delete' && n.type() === Button; + }) + .simulate('click'); + expect(deleted).toBe(true); + }); +}); diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx new file mode 100644 index 0000000..e519d24 --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -0,0 +1,181 @@ +import React, { PureComponent, SyntheticEvent } from 'react'; +import { cx, css } from '@emotion/css'; +import { stylesFactory, withTheme } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; +import { Themeable } from '../../types'; +import { ComponentSize } from '../../types/size'; +import { Button, ButtonVariant } from '../Button'; + +export interface Props extends Themeable { + /** Confirm action callback */ + onConfirm(): void; + /** Custom button styles */ + className?: string; + /** Button size */ + size?: ComponentSize; + /** Text for the Confirm button */ + confirmText?: string; + /** Disable button click action */ + disabled?: boolean; + /** Variant of the Confirm button */ + confirmVariant?: ButtonVariant; + /** Hide confirm actions when after of them is clicked */ + closeOnConfirm?: boolean; + + /** Optional on click handler for the original button */ + onClick?(): void; + /** Callback for the cancel action */ + onCancel?(): void; +} + +interface State { + showConfirm: boolean; +} + +class UnThemedConfirmButton extends PureComponent { + state: State = { + showConfirm: false, + }; + + onClickButton = (event: SyntheticEvent) => { + if (event) { + event.preventDefault(); + } + + this.setState({ + showConfirm: true, + }); + + if (this.props.onClick) { + this.props.onClick(); + } + }; + + onClickCancel = (event: SyntheticEvent) => { + if (event) { + event.preventDefault(); + } + this.setState({ + showConfirm: false, + }); + if (this.props.onCancel) { + this.props.onCancel(); + } + }; + onConfirm = (event: SyntheticEvent) => { + if (event) { + event.preventDefault(); + } + this.props.onConfirm(); + if (this.props.closeOnConfirm) { + this.setState({ + showConfirm: false, + }); + } + }; + + render() { + const { + theme, + className, + size, + disabled, + confirmText, + confirmVariant: confirmButtonVariant, + children, + } = this.props; + const styles = getStyles(theme); + const buttonClass = cx( + className, + this.state.showConfirm ? styles.buttonHide : styles.buttonShow, + disabled && styles.buttonDisabled + ); + const confirmButtonClass = cx( + styles.confirmButton, + this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide + ); + + const onClick = disabled ? () => {} : this.onClickButton; + + return ( + + {typeof children === 'string' ? ( + + + + ) : ( + + {children} + + )} + + + + + + ); + } +} + +export const ConfirmButton = withTheme(UnThemedConfirmButton); + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + buttonContainer: css` + direction: rtl; + display: flex; + align-items: center; + `, + buttonDisabled: css` + text-decoration: none; + color: ${theme.colors.text}; + opacity: 0.65; + cursor: not-allowed; + pointer-events: none; + `, + buttonShow: css` + opacity: 1; + transition: opacity 0.1s ease; + z-index: 2; + `, + buttonHide: css` + opacity: 0; + transition: opacity 0.1s ease; + z-index: 0; + `, + confirmButton: css` + align-items: flex-start; + background: ${theme.colors.bg1}; + display: flex; + overflow: hidden; + position: absolute; + `, + confirmButtonShow: css` + z-index: 1; + opacity: 1; + transition: opacity 0.08s ease-out, transform 0.1s ease-out; + transform: translateX(0); + `, + confirmButtonHide: css` + opacity: 0; + transition: opacity 0.12s ease-in, transform 0.14s ease-in; + transform: translateX(100px); + `, + }; +}); + +// Declare defaultProps directly on the themed component so they are displayed +// in the props table +ConfirmButton.defaultProps = { + size: 'md', + confirmText: 'Save', + disabled: false, + confirmVariant: 'primary', +}; +ConfirmButton.displayName = 'ConfirmButton'; diff --git a/packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx new file mode 100644 index 0000000..1f73f30 --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { ConfirmButton } from './ConfirmButton'; +import { ComponentSize } from '../../types/size'; +import { Button } from '../Button'; + +export interface Props { + /** Confirm action callback */ + onConfirm(): void; + /** Button size */ + size?: ComponentSize; + /** Disable button click action */ + disabled?: boolean; +} + +export const DeleteButton: FC = ({ size, disabled, onConfirm }) => { + return ( + + + + {onAlternative ? ( + + ) : null} + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + modal: css` + width: 500px; + `, + modalText: css({ + fontSize: theme.typography.h5.fontSize, + color: theme.colors.text.primary, + }), + modalDescription: css({ + fontSize: theme.typography.body.fontSize, + }), + modalConfirmationInput: css({ + paddingTop: theme.spacing(1), + }), +}); diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx new file mode 100644 index 0000000..4f5176f --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx @@ -0,0 +1,31 @@ +import { Props } from '@storybook/addon-docs/blocks'; +import { ContextMenu } from './ContextMenu'; +import { WithContextMenu } from "./WithContextMenu"; + +# ContextMenu + +A menu displaying additional options when it's not possible to show them at all times due to a space constraint. + +### Usage + +There are controlled and uncontrolled versions of the component available. With the controlled component (`ContextMenu`) the open/close logic needs to be handled separately. Uncontrolled component (`WithContextMenu`) handles this logic internally. + +#### Controlled component + +```jsx + {}} items={[{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]} /> +``` + +#### Uncontrolled component + +```jsx + [{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]}> + {({ openMenu }) => } + +``` + +### Props of ContextMenu + + +### Props of WithContextMenu + diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx new file mode 100644 index 0000000..773b78d --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { IconButton } from '../IconButton/IconButton'; +import { ContextMenu } from './ContextMenu'; +import { WithContextMenu } from './WithContextMenu'; +import mdx from './ContextMenu.mdx'; +import { MenuGroup } from '../Menu/MenuGroup'; +import { MenuItem } from '../Menu/MenuItem'; + +export default { + title: 'General/ContextMenu', + component: ContextMenu, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const menuItems = [ + { + label: 'Test', + items: [ + { label: 'First', ariaLabel: 'First' }, + { label: 'Second', ariaLabel: 'Second' }, + ], + }, +]; + +const renderMenuItems = () => { + return menuItems?.map((group, index) => ( + + {(group.items || []).map((item) => ( + + ))} + + )); +}; + +export const Basic = () => { + return {}} renderMenuItems={renderMenuItems} />; +}; + +export const WithState = () => { + return ( + + {({ openMenu }) => } + + ); +}; diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000..26b7019 --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,67 @@ +import React, { useRef, useState, useLayoutEffect } from 'react'; +import { selectors } from '@grafana/e2e-selectors'; +import { useClickAway } from 'react-use'; +import { Portal } from '../Portal/Portal'; +import { Menu } from '../Menu/Menu'; + +export interface ContextMenuProps { + /** Starting horizontal position for the menu */ + x: number; + /** Starting vertical position for the menu */ + y: number; + /** Callback for closing the menu */ + onClose?: () => void; + /** RenderProp function that returns menu items to display */ + renderMenuItems?: () => React.ReactNode; + /** A function that returns header element */ + renderHeader?: () => React.ReactNode; +} + +export const ContextMenu: React.FC = React.memo( + ({ x, y, onClose, renderMenuItems, renderHeader }) => { + const menuRef = useRef(null); + const [positionStyles, setPositionStyles] = useState({}); + + useLayoutEffect(() => { + const menuElement = menuRef.current; + if (menuElement) { + const rect = menuElement.getBoundingClientRect(); + const OFFSET = 5; + const collisions = { + right: window.innerWidth < x + rect.width, + bottom: window.innerHeight < rect.bottom + rect.height + OFFSET, + }; + + setPositionStyles({ + position: 'fixed', + left: collisions.right ? x - rect.width - OFFSET : x - OFFSET, + top: collisions.bottom ? y - rect.height - OFFSET : y + OFFSET, + }); + } + }, [x, y]); + + useClickAway(menuRef, () => { + if (onClose) { + onClose(); + } + }); + const header = renderHeader && renderHeader(); + const menuItems = renderMenuItems && renderMenuItems(); + + return ( + + + {menuItems} + + + ); + } +); + +ContextMenu.displayName = 'ContextMenu'; diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx new file mode 100644 index 0000000..f7c6944 --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { ContextMenu } from '../ContextMenu/ContextMenu'; + +interface WithContextMenuProps { + /** Menu item trigger that accepts openMenu prop */ + children: (props: { openMenu: React.MouseEventHandler }) => JSX.Element; + /** A function that returns an array of menu items */ + renderMenuItems: () => React.ReactNode; +} + +export const WithContextMenu: React.FC = ({ children, renderMenuItems }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + return ( + <> + {children({ + openMenu: (e) => { + setIsMenuOpen(true); + setMenuPosition({ + x: e.pageX, + y: e.pageY, + }); + }, + })} + + {isMenuOpen && ( + setIsMenuOpen(false)} + x={menuPosition.x} + y={menuPosition.y} + renderMenuItems={renderMenuItems} + /> + )} + + ); +}; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx new file mode 100644 index 0000000..32375a5 --- /dev/null +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { CustomScrollbar } from './CustomScrollbar'; + +describe('CustomScrollbar', () => { + it('renders correctly', () => { + const tree = renderer + .create( + +

Scrollable content

+
+ ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx new file mode 100644 index 0000000..15f23fe --- /dev/null +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -0,0 +1,176 @@ +import React, { FC, useCallback, useEffect, useRef } from 'react'; +import { isNil } from 'lodash'; +import classNames from 'classnames'; +import { css } from '@emotion/css'; +import Scrollbars from 'react-custom-scrollbars'; +import { useStyles2 } from '../../themes'; +import { GrafanaTheme2 } from '@grafana/data'; + +interface Props { + className?: string; + autoHide?: boolean; + autoHideTimeout?: number; + autoHeightMax?: string; + hideTracksWhenNotNeeded?: boolean; + hideHorizontalTrack?: boolean; + hideVerticalTrack?: boolean; + scrollTop?: number; + setScrollTop?: (event: any) => void; + autoHeightMin?: number | string; + updateAfterMountMs?: number; +} + +/** + * Wraps component into component from `react-custom-scrollbars` + */ +export const CustomScrollbar: FC = ({ + autoHide = false, + autoHideTimeout = 200, + setScrollTop, + className, + autoHeightMin = '0', + autoHeightMax = '100%', + hideTracksWhenNotNeeded = false, + hideHorizontalTrack, + hideVerticalTrack, + updateAfterMountMs, + scrollTop, + children, +}) => { + const ref = useRef(null); + const styles = useStyles2(getStyles); + + const updateScroll = () => { + if (ref.current && !isNil(scrollTop)) { + ref.current.scrollTop(scrollTop); + } + }; + + useEffect(() => { + updateScroll(); + }); + + /** + * Special logic for doing a update a few milliseconds after mount to check for + * updated height due to dynamic content + */ + + useEffect(() => { + if (!updateAfterMountMs) { + return; + } + setTimeout(() => { + const scrollbar = ref.current as any; + if (scrollbar?.update) { + scrollbar.update(); + } + }, updateAfterMountMs); + }, [updateAfterMountMs]); + + function renderTrack(className: string, hideTrack: boolean | undefined, passedProps: any) { + if (passedProps.style && hideTrack) { + passedProps.style.display = 'none'; + } + + return
; + } + + const renderTrackHorizontal = useCallback( + (passedProps: any) => { + return renderTrack('track-horizontal', hideHorizontalTrack, passedProps); + }, + [hideHorizontalTrack] + ); + + const renderTrackVertical = useCallback( + (passedProps: any) => { + return renderTrack('track-vertical', hideVerticalTrack, passedProps); + }, + [hideVerticalTrack] + ); + + const renderThumbHorizontal = useCallback((passedProps: any) => { + return
; + }, []); + + const renderThumbVertical = useCallback((passedProps: any) => { + return
; + }, []); + + const renderView = useCallback((passedProps: any) => { + return
; + }, []); + + return ( + + {children} + + ); +}; + +export default CustomScrollbar; + +const getStyles = (theme: GrafanaTheme2) => { + return { + customScrollbar: css` + // Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to + // make scroll working it should fit outer container size (scroll appears only when inner container size is + // greater than outer one). + display: flex; + flex-grow: 1; + .scrollbar-view { + display: flex; + flex-grow: 1; + flex-direction: column; + } + .track-vertical { + border-radius: ${theme.shape.borderRadius(2)}; + width: ${theme.spacing(1)} !important; + right: 0px; + bottom: ${theme.spacing(0.25)}; + top: ${theme.spacing(0.25)}; + } + .track-horizontal { + border-radius: ${theme.shape.borderRadius(2)}; + height: ${theme.spacing(1)} !important; + right: ${theme.spacing(0.25)}; + bottom: ${theme.spacing(0.25)}; + left: ${theme.spacing(0.25)}; + } + .thumb-vertical { + background: ${theme.colors.action.focus}; + border-radius: ${theme.shape.borderRadius(2)}; + opacity: 0; + } + .thumb-horizontal { + background: ${theme.colors.action.focus}; + border-radius: ${theme.shape.borderRadius(2)}; + opacity: 0; + } + &:hover { + .thumb-vertical, + .thumb-horizontal { + opacity: 1; + transition: opacity 0.3s ease-in-out; + } + } + `, + }; +}; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap new file mode 100644 index 0000000..1601b7b --- /dev/null +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomScrollbar renders correctly 1`] = ` +
+
+

+ Scrollable content +

+
+
+
+
+
+
+
+
+`; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx new file mode 100644 index 0000000..95d8b8d --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx @@ -0,0 +1,35 @@ +import { Field, LinkModel } from '@grafana/data'; +import React from 'react'; +import { ButtonProps, Button } from '../Button'; + +type DataLinkButtonProps = { + link: LinkModel; + buttonProps?: ButtonProps; +}; + +/** + * @internal + */ +export function DataLinkButton({ link, buttonProps }: DataLinkButtonProps) { + return ( + { + if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) { + event.preventDefault(); + link.onClick(event); + } + } + : undefined + } + > + + + ); +} diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx new file mode 100644 index 0000000..b433d02 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx @@ -0,0 +1,69 @@ +import React, { ChangeEvent } from 'react'; +import { VariableSuggestion, GrafanaTheme2, DataLink } from '@grafana/data'; +import { Switch } from '../Switch/Switch'; +import { css } from '@emotion/css'; +import { useStyles2 } from '../../themes/index'; +import { DataLinkInput } from './DataLinkInput'; +import { Field } from '../Forms/Field'; +import { Input } from '../Input/Input'; + +interface DataLinkEditorProps { + index: number; + isLast: boolean; + value: DataLink; + suggestions: VariableSuggestion[]; + onChange: (index: number, link: DataLink, callback?: () => void) => void; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + listItem: css` + margin-bottom: ${theme.spacing()}; + `, + infoText: css` + padding-bottom: ${theme.spacing(2)}; + margin-left: 66px; + color: ${theme.colors.text.secondary}; + `, +}); + +export const DataLinkEditor: React.FC = React.memo( + ({ index, value, onChange, suggestions, isLast }) => { + const styles = useStyles2(getStyles); + + const onUrlChange = (url: string, callback?: () => void) => { + onChange(index, { ...value, url }, callback); + }; + const onTitleChange = (event: ChangeEvent) => { + onChange(index, { ...value, title: event.target.value }); + }; + + const onOpenInNewTabChanged = () => { + onChange(index, { ...value, targetBlank: !value.targetBlank }); + }; + + return ( +
+ + + + + + + + + + + + + {isLast && ( +
+ With data links you can reference data variables like series name, labels and values. Type CMD+Space, + CTRL+Space, or $ to open variable suggestions. +
+ )} +
+ ); + } +); + +DataLinkEditor.displayName = 'DataLinkEditor'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx new file mode 100644 index 0000000..387358c --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -0,0 +1,224 @@ +import React, { memo, RefObject, useEffect, useMemo, useRef, useState } from 'react'; +import usePrevious from 'react-use/lib/usePrevious'; +import { DataLinkSuggestions } from './DataLinkSuggestions'; +import { makeValue } from '../../index'; +import { SelectionReference } from './SelectionReference'; +import { Portal } from '../index'; + +// @ts-ignore +import Prism, { Grammar, LanguageMap } from 'prismjs'; +import { Editor } from '@grafana/slate-react'; +import { Value } from 'slate'; +import Plain from 'slate-plain-serializer'; +import { Popper as ReactPopper } from 'react-popper'; +import { css, cx } from '@emotion/css'; + +import { SlatePrism } from '../../slate-plugins'; +import { SCHEMA } from '../../utils/slate'; +import { useStyles2 } from '../../themes'; +import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data'; +import { getInputStyles } from '../Input/Input'; +import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; + +const modulo = (a: number, n: number) => a - n * Math.floor(a / n); + +interface DataLinkInputProps { + value: string; + onChange: (url: string, callback?: () => void) => void; + suggestions: VariableSuggestion[]; + placeholder?: string; +} + +const datalinksSyntax: Grammar = { + builtInVariable: { + pattern: /(\${\S+?})/, + }, +}; + +const plugins = [ + SlatePrism( + { + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: () => 'links', + }, + { ...(Prism.languages as LanguageMap), links: datalinksSyntax } + ), +]; + +const getStyles = (theme: GrafanaTheme2) => ({ + input: getInputStyles({ theme, invalid: false }).input, + editor: css` + .token.builtInVariable { + color: ${theme.colors.success.text}; + } + .token.variable { + color: ${theme.colors.primary.text}; + } + `, + // Wrapper with child selector needed. + // When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working + wrapperOverrides: css` + width: 100%; + > .slate-query-field__wrapper { + padding: 0; + background-color: transparent; + border: none; + } + `, +}); + +// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this +// was used and changes to different state were propagated here. +export const DataLinkInput: React.FC = memo( + ({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => { + const editorRef = useRef() as RefObject; + const styles = useStyles2(getStyles); + const [showingSuggestions, setShowingSuggestions] = useState(false); + const [suggestionsIndex, setSuggestionsIndex] = useState(0); + const [linkUrl, setLinkUrl] = useState(makeValue(value)); + const prevLinkUrl = usePrevious(linkUrl); + + // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 + const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); + stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; + + // Used to get the height of the suggestion elements in order to scroll to them. + const activeRef = useRef(null); + const activeIndexPosition = useMemo(() => getElementPosition(activeRef.current, suggestionsIndex), [ + suggestionsIndex, + ]); + + // SelectionReference is used to position the variables suggestion relatively to current DOM selection + const selectionRef = useMemo(() => new SelectionReference(), []); + + const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => { + if (!stateRef.current.showingSuggestions) { + if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { + return setShowingSuggestions(true); + } + return next(); + } + + switch (event.key) { + case 'Backspace': + case 'Escape': + setShowingSuggestions(false); + return setSuggestionsIndex(0); + + case 'Enter': + event.preventDefault(); + return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]); + + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + const direction = event.key === 'ArrowDown' ? 1 : -1; + return setSuggestionsIndex((index) => modulo(index + direction, stateRef.current.suggestions.length)); + default: + return next(); + } + }, []); + + useEffect(() => { + // Update the state of the link in the parent. This is basically done on blur but we need to do it after + // our state have been updated. The duplicity of state is done for perf reasons and also because local + // state also contains things like selection and formating. + if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) { + stateRef.current.onChange(Plain.serialize(linkUrl)); + } + }, [linkUrl, prevLinkUrl]); + + const onUrlChange = React.useCallback(({ value }: { value: Value }) => { + setLinkUrl(value); + }, []); + + const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => { + const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$'; + if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { + editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); + } else { + editor.insertText(`\${${item.value}:queryparam}`); + } + + setLinkUrl(editor.value); + setShowingSuggestions(false); + + setSuggestionsIndex(0); + stateRef.current.onChange(Plain.serialize(editor.value)); + }; + + return ( +
+
+
+ {showingSuggestions && ( + + + {({ ref, style, placement }) => { + return ( +
+ + setShowingSuggestions(false)} + activeIndex={suggestionsIndex} + /> + +
+ ); + }} +
+
+ )} + onKeyDown(event as KeyboardEvent, next)} + plugins={plugins} + className={cx( + styles.editor, + styles.input, + css` + padding: 3px 8px; + ` + )} + /> +
+
+
+ ); + } +); + +DataLinkInput.displayName = 'DataLinkInput'; + +function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) { + return (suggestionElement?.clientHeight ?? 0) * activeIndex; +} diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx new file mode 100644 index 0000000..91100d2 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -0,0 +1,138 @@ +import { VariableSuggestion, GrafanaTheme2 } from '@grafana/data'; +import { css, cx } from '@emotion/css'; +import { groupBy, capitalize } from 'lodash'; +import React, { useRef, useMemo } from 'react'; +import useClickAway from 'react-use/lib/useClickAway'; +import { List } from '../index'; +import { useStyles2 } from '../../themes'; + +interface DataLinkSuggestionsProps { + activeRef?: React.RefObject; + suggestions: VariableSuggestion[]; + activeIndex: number; + onSuggestionSelect: (suggestion: VariableSuggestion) => void; + onClose?: () => void; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + list: css` + border-bottom: 1px solid ${theme.colors.border.weak}; + &:last-child { + border: none; + } + `, + wrapper: css` + background: ${theme.colors.background.primary}; + width: 250px; + box-shadow: 0 5px 10px 0 ${theme.shadows.z1}; + `, + item: css` + background: none; + padding: 2px 8px; + color: ${theme.colors.text.primary}; + cursor: pointer; + &:hover { + background: ${theme.colors.action.hover}; + } + `, + label: css` + color: ${theme.colors.text.secondary}; + `, + activeItem: css` + background: ${theme.colors.background.secondary}; + &:hover { + background: ${theme.colors.background.secondary}; + } + `, + itemValue: css` + font-family: ${theme.typography.fontFamilyMonospace}; + font-size: ${theme.typography.size.sm}; + `, + }; +}; + +export const DataLinkSuggestions: React.FC = ({ suggestions, ...otherProps }) => { + const ref = useRef(null); + + useClickAway(ref, () => { + if (otherProps.onClose) { + otherProps.onClose(); + } + }); + + const groupedSuggestions = useMemo(() => { + return groupBy(suggestions, (s) => s.origin); + }, [suggestions]); + + const styles = useStyles2(getStyles); + + return ( +
+ {Object.keys(groupedSuggestions).map((key, i) => { + const indexOffset = + i === 0 + ? 0 + : Object.keys(groupedSuggestions).reduce((acc, current, index) => { + if (index >= i) { + return acc; + } + return acc + groupedSuggestions[current].length; + }, 0); + + return ( + + ); + })} +
+ ); +}; + +DataLinkSuggestions.displayName = 'DataLinkSuggestions'; + +interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps { + label: string; + activeIndexOffset: number; + activeRef?: React.RefObject; +} + +const DataLinkSuggestionsList: React.FC = React.memo( + ({ activeIndex, activeIndexOffset, label, onClose, onSuggestionSelect, suggestions, activeRef: selectedRef }) => { + const styles = useStyles2(getStyles); + + return ( + <> + { + const isActive = index + activeIndexOffset === activeIndex; + return ( +
{ + onSuggestionSelect(item); + }} + title={item.documentation} + > + + {label} {item.label} + +
+ ); + }} + /> + + ); + } +); + +DataLinkSuggestionsList.displayName = 'DataLinkSuggestionsList'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx new file mode 100644 index 0000000..9ac75b3 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DataLinksContextMenu } from './DataLinksContextMenu'; +import { selectors } from '@grafana/e2e-selectors'; + +const fakeAriaLabel = 'fake aria label'; +describe('DataLinksContextMenu', () => { + it('renders context menu when there are more than one data links', () => { + render( + [ + { + href: '/link1', + title: 'Link1', + target: '_blank', + origin: {}, + }, + { + href: '/link2', + title: 'Link2', + target: '_blank', + origin: {}, + }, + ]} + config={{ + links: [ + { + title: 'Link1', + url: '/link1', + }, + { + title: 'Link2', + url: '/link2', + }, + ], + }} + > + {() => { + return
; + }} + + ); + + expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument(); + expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0); + }); + + it('renders link when there is a single data link', () => { + render( + [ + { + href: '/link1', + title: 'Link1', + target: '_blank', + origin: {}, + }, + ]} + config={{ + links: [ + { + title: 'Link1', + url: '/link1', + }, + ], + }} + > + {() => { + return
; + }} + + ); + + expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument(); + expect(screen.getByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx new file mode 100644 index 0000000..13467c4 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { FieldConfig, LinkModel } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { css } from '@emotion/css'; +import { WithContextMenu } from '../ContextMenu/WithContextMenu'; +import { linkModelToContextMenuItems } from '../../utils/dataLinks'; +import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup'; +import { MenuItem } from '../Menu/MenuItem'; + +interface DataLinksContextMenuProps { + children: (props: DataLinksContextMenuApi) => JSX.Element; + links: () => LinkModel[]; + config: FieldConfig; +} + +export interface DataLinksContextMenuApi { + openMenu?: React.MouseEventHandler; + targetClassName?: string; +} + +export const DataLinksContextMenu: React.FC = ({ children, links, config }) => { + const linksCounter = config.links!.length; + const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }]; + const renderMenuGroupItems = () => { + return itemsGroup.map((group, index) => ( + + {(group.items || []).map((item) => ( + + ))} + + )); + }; + + // Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization + const targetClassName = css` + cursor: context-menu; + `; + + if (linksCounter > 1) { + return ( + + {({ openMenu }) => { + return children({ openMenu, targetClassName }); + }} + + ); + } else { + const linkModel = links()[0]; + return ( + + {children({})} + + ); + } +}; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx new file mode 100644 index 0000000..3d3a25f --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx @@ -0,0 +1,49 @@ +import { DataFrame, DataLink, VariableSuggestion } from '@grafana/data'; +import React, { FC, useState } from 'react'; +import { DataLinkEditor } from '../DataLinkEditor'; +import { Button } from '../../Button'; +import { Modal } from '../../Modal/Modal'; + +interface DataLinkEditorModalContentProps { + link: DataLink; + index: number; + data: DataFrame[]; + getSuggestions: () => VariableSuggestion[]; + onSave: (index: number, ink: DataLink) => void; + onCancel: (index: number) => void; +} + +export const DataLinkEditorModalContent: FC = ({ + link, + index, + getSuggestions, + onSave, + onCancel, +}) => { + const [dirtyLink, setDirtyLink] = useState(link); + return ( + <> + { + setDirtyLink(link); + }} + /> + + + + + + ); +}; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx new file mode 100644 index 0000000..fa61684 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx @@ -0,0 +1,120 @@ +import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data'; +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import { Button } from '../../Button/Button'; +import { cloneDeep } from 'lodash'; +import { Modal } from '../../Modal/Modal'; +import { stylesFactory, useTheme2 } from '../../../themes'; +import { DataLinksListItem } from './DataLinksListItem'; +import { DataLinkEditorModalContent } from './DataLinkEditorModalContent'; + +interface DataLinksInlineEditorProps { + links?: DataLink[]; + onChange: (links: DataLink[]) => void; + getSuggestions: () => VariableSuggestion[]; + data: DataFrame[]; +} + +export const DataLinksInlineEditor: React.FC = ({ + links, + onChange, + getSuggestions, + data, +}) => { + const theme = useTheme2(); + const [editIndex, setEditIndex] = useState(null); + const [isNew, setIsNew] = useState(false); + + const styles = getDataLinksInlineEditorStyles(theme); + const linksSafe: DataLink[] = links ?? []; + const isEditing = editIndex !== null; + + const onDataLinkChange = (index: number, link: DataLink) => { + if (isNew) { + if (link.title.trim() === '' && link.url.trim() === '') { + setIsNew(false); + setEditIndex(null); + return; + } else { + setEditIndex(null); + setIsNew(false); + } + } + const update = cloneDeep(linksSafe); + update[index] = link; + onChange(update); + setEditIndex(null); + }; + + const onDataLinkAdd = () => { + let update = cloneDeep(linksSafe); + setEditIndex(update.length); + setIsNew(true); + }; + + const onDataLinkCancel = (index: number) => { + if (isNew) { + setIsNew(false); + } + setEditIndex(null); + }; + + const onDataLinkRemove = (index: number) => { + const update = cloneDeep(linksSafe); + update.splice(index, 1); + onChange(update); + }; + + return ( + <> + {linksSafe.length > 0 && ( +
+ {linksSafe.map((l, i) => { + return ( + setEditIndex(i)} + onRemove={() => onDataLinkRemove(i)} + data={data} + /> + ); + })} +
+ )} + + {isEditing && editIndex !== null && ( + { + onDataLinkCancel(editIndex); + }} + > + + + )} + + + + ); +}; + +const getDataLinksInlineEditorStyles = stylesFactory((theme: GrafanaTheme2) => { + return { + wrapper: css` + margin-bottom: ${theme.spacing(2)}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx new file mode 100644 index 0000000..850f00b --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DataLinksListItem, DataLinksListItemProps } from './DataLinksListItem'; + +const baseLink = { + url: '', + title: '', + onBuildUrl: jest.fn(), + onClick: jest.fn(), +}; + +function setupTestContext(options: Partial) { + const defaults: DataLinksListItemProps = { + index: 0, + link: baseLink, + data: [], + onChange: jest.fn(), + onEdit: jest.fn(), + onRemove: jest.fn(), + }; + + const props = { ...defaults, ...options }; + const { rerender } = render(); + + return { rerender, props }; +} + +describe('DataLinksListItem', () => { + describe('when link has title', () => { + it('then the link title should be visible', () => { + const link = { + ...baseLink, + title: 'Some Data Link Title', + }; + setupTestContext({ link }); + + expect(screen.getByText(/some data link title/i)).toBeInTheDocument(); + }); + }); + + describe('when link has url', () => { + it('then the link url should be visible', () => { + const link = { + ...baseLink, + url: 'http://localhost:3000', + }; + setupTestContext({ link }); + + expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument(); + expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument(); + }); + }); + + describe('when link is missing title', () => { + it('then the link title should be replaced by [Data link title not provided]', () => { + const link = { + ...baseLink, + title: (undefined as unknown) as string, + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument(); + }); + }); + + describe('when link is missing url', () => { + it('then the link url should be replaced by [Data link url not provided]', () => { + const link = { + ...baseLink, + url: (undefined as unknown) as string, + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument(); + expect(screen.getByTitle('')).toBeInTheDocument(); + }); + }); + + describe('when link title is empty', () => { + it('then the link title should be replaced by [Data link title not provided]', () => { + const link = { + ...baseLink, + title: ' ', + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument(); + }); + }); + + describe('when link url is empty', () => { + it('then the link url should be replaced by [Data link url not provided]', () => { + const link = { + ...baseLink, + url: ' ', + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument(); + expect(screen.getByTitle('')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx new file mode 100644 index 0000000..5fc55c3 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx @@ -0,0 +1,72 @@ +import React, { FC } from 'react'; +import { css, cx } from '@emotion/css'; +import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data'; +import { stylesFactory, useTheme2 } from '../../../themes'; +import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout'; +import { IconButton } from '../../IconButton/IconButton'; + +export interface DataLinksListItemProps { + index: number; + link: DataLink; + data: DataFrame[]; + onChange: (index: number, link: DataLink) => void; + onEdit: () => void; + onRemove: () => void; + isEditing?: boolean; +} + +export const DataLinksListItem: FC = ({ link, onEdit, onRemove }) => { + const theme = useTheme2(); + const styles = getDataLinkListItemStyles(theme); + const { title = '', url = '' } = link; + + const hasTitle = title.trim() !== ''; + const hasUrl = url.trim() !== ''; + + return ( +
+ + +
+ {hasTitle ? title : 'Data link title not provided'} +
+ + + + +
+
+ {hasUrl ? url : 'Data link url not provided'} +
+
+
+ ); +}; + +const getDataLinkListItemStyles = stylesFactory((theme: GrafanaTheme2) => { + return { + wrapper: css` + margin-bottom: ${theme.spacing(2)}; + width: 100%; + &:last-child { + margin-bottom: 0; + } + `, + notConfigured: css` + font-style: italic; + `, + title: css` + color: ${theme.colors.text.primary}; + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.fontWeightMedium}; + `, + url: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.size.sm}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90%; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx new file mode 100644 index 0000000..0124ac5 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx @@ -0,0 +1,71 @@ +import { Field, GrafanaTheme, LinkModel } from '@grafana/data'; +import { css } from '@emotion/css'; +import React from 'react'; +import { useStyles } from '../../themes'; +import { Icon } from '../Icon/Icon'; +import { DataLinkButton } from './DataLinkButton'; + +type Props = { + links: Array>; +}; + +/** + * @internal + */ +export function FieldLinkList({ links }: Props) { + const styles = useStyles(getStyles); + + if (links.length === 1) { + return ; + } + + const externalLinks = links.filter((link) => link.target === '_blank'); + const internalLinks = links.filter((link) => link.target === '_self'); + + return ( + <> + {internalLinks.map((link, i) => { + return ; + })} +
+

External links

+ {externalLinks.map((link, i) => ( + + + {link.title} + + ))} +
+ + ); +} + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + flex-basis: 150px; + width: 100px; + margin-top: ${theme.spacing.sm}; + `, + externalLinksHeading: css` + color: ${theme.colors.textWeak}; + font-weight: ${theme.typography.weight.regular}; + font-size: ${theme.typography.size.sm}; + margin: 0; + `, + externalLink: css` + color: ${theme.colors.linkExternal}; + font-weight: ${theme.typography.weight.regular}; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + + div { + margin-right: ${theme.spacing.sm}; + } + `, +}); diff --git a/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts b/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts new file mode 100644 index 0000000..fa6964f --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts @@ -0,0 +1,28 @@ +export class SelectionReference { + getBoundingClientRect() { + const selection = window.getSelection(); + const node = selection && selection.anchorNode; + + if (node && node.parentElement) { + const rect = node.parentElement.getBoundingClientRect(); + return rect; + } + + return { + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }; + } + + get clientWidth() { + return this.getBoundingClientRect().width; + } + + get clientHeight() { + return this.getBoundingClientRect().height; + } +} diff --git a/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx new file mode 100644 index 0000000..e745950 --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { InlineField } from '../..'; +import { HttpSettingsProps } from './types'; +import { FormField } from '../FormField/FormField'; +import { SecretFormField } from '../SecretFormField/SecretFormField'; + +export const BasicAuthSettings: React.FC = ({ dataSourceConfig, onChange }) => { + const password = dataSourceConfig.secureJsonData ? dataSourceConfig.secureJsonData.basicAuthPassword : ''; + + const onPasswordReset = () => { + onChange({ + ...dataSourceConfig, + basicAuthPassword: '', + secureJsonData: { + ...dataSourceConfig.secureJsonData, + basicAuthPassword: '', + }, + secureJsonFields: { + ...dataSourceConfig.secureJsonFields, + basicAuthPassword: false, + }, + }); + }; + + const onPasswordChange = (event: React.SyntheticEvent) => { + onChange({ + ...dataSourceConfig, + secureJsonData: { + ...dataSourceConfig.secureJsonData, + basicAuthPassword: event.currentTarget.value, + }, + }); + }; + + return ( + <> + + onChange({ ...dataSourceConfig, basicAuthUser: event.currentTarget.value })} + /> + + + + + + ); +}; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx new file mode 100644 index 0000000..1b8406c --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx @@ -0,0 +1,31 @@ +import React, { ChangeEvent, MouseEvent, FC } from 'react'; +import { Input } from '../Input/Input'; +import { Button } from '../Button'; +import { TextArea } from '../TextArea/TextArea'; +import { InlineField } from '../Forms/InlineField'; + +interface Props { + label: string; + hasCert: boolean; + placeholder: string; + + onChange: (event: ChangeEvent) => void; + onClick: (event: MouseEvent) => void; +} + +export const CertificationKey: FC = ({ hasCert, label, onChange, onClick, placeholder }) => { + return ( + + {hasCert ? ( + <> + + + + ) : ( + + + If you want to apply templating to the alert rule name, use the following syntax - ${Label} + +
+
+ Tags +
+
+ + + +
+
+
+ + +
+
+ +
+
+
diff --git a/public/app/features/alerting/state/AlertingQueryRunner.test.ts b/public/app/features/alerting/state/AlertingQueryRunner.test.ts new file mode 100644 index 0000000..9e250ab --- /dev/null +++ b/public/app/features/alerting/state/AlertingQueryRunner.test.ts @@ -0,0 +1,243 @@ +import { + ArrayVector, + DataFrame, + DataFrameJSON, + Field, + FieldType, + getDefaultRelativeTimeRange, + LoadingState, + rangeUtil, +} from '@grafana/data'; +import { FetchResponse } from '@grafana/runtime'; +import { BackendSrv } from 'app/core/services/backend_srv'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { Observable, of, throwError } from 'rxjs'; +import { delay, take } from 'rxjs/operators'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { AlertingQueryResponse, AlertingQueryRunner } from './AlertingQueryRunner'; + +describe('AlertingQueryRunner', () => { + it('should successfully map response and return panel data by refId', async () => { + const response = createFetchResponse({ + results: { + A: { frames: [createDataFrameJSON([1, 2, 3])] }, + B: { frames: [createDataFrameJSON([5, 6])] }, + }, + }); + + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => of(response), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(1))).toEmitValuesWith((values) => { + const [data] = values; + expect(data).toEqual({ + A: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238, 1620051632238], + values: [1, 2, 3], + }), + ], + structureRev: 1, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + B: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238], + values: [5, 6], + }), + ], + structureRev: 1, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + }); + }); + }); + + it('should successfully map response with sliding relative time range', async () => { + const response = createFetchResponse({ + results: { + A: { frames: [createDataFrameJSON([1, 2, 3])] }, + B: { frames: [createDataFrameJSON([5, 6])] }, + }, + }); + + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => of(response), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(1))).toEmitValuesWith((values) => { + const [data] = values; + const relativeA = rangeUtil.timeRangeToRelative(data.A.timeRange); + const relativeB = rangeUtil.timeRangeToRelative(data.B.timeRange); + const expected = getDefaultRelativeTimeRange(); + + expect(relativeA).toEqual(expected); + expect(relativeB).toEqual(expected); + }); + }); + + it('should emit loading state if response is slower then 200ms', async () => { + const response = createFetchResponse({ + results: { + A: { frames: [createDataFrameJSON([1, 2, 3])] }, + B: { frames: [createDataFrameJSON([5, 6])] }, + }, + }); + + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => of(response).pipe(delay(210)), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(2))).toEmitValuesWith((values) => { + const [loading, data] = values; + + expect(loading.A.state).toEqual(LoadingState.Loading); + expect(loading.B.state).toEqual(LoadingState.Loading); + + expect(data).toEqual({ + A: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238, 1620051632238], + values: [1, 2, 3], + }), + ], + structureRev: 2, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + B: { + annotations: [], + state: LoadingState.Done, + series: [ + expectDataFrameWithValues({ + time: [1620051612238, 1620051622238], + values: [5, 6], + }), + ], + structureRev: 2, + timeRange: expect.anything(), + timings: { + dataProcessingTime: expect.any(Number), + }, + }, + }); + }); + }); + + it('should emit error state if fetch request fails', async () => { + const error = new Error('could not query data'); + const runner = new AlertingQueryRunner( + mockBackendSrv({ + fetch: () => throwError(error), + }) + ); + + const data = runner.get(); + runner.run([createQuery('A'), createQuery('B')]); + + await expect(data.pipe(take(1))).toEmitValuesWith((values) => { + const [data] = values; + + expect(data.A.state).toEqual(LoadingState.Error); + expect(data.A.error).toEqual(error); + + expect(data.B.state).toEqual(LoadingState.Error); + expect(data.B.error).toEqual(error); + }); + }); +}); + +type MockBackendSrvConfig = { + fetch: () => Observable>; +}; + +const mockBackendSrv = ({ fetch }: MockBackendSrvConfig): BackendSrv => { + return ({ + fetch, + resolveCancelerIfExists: jest.fn(), + } as unknown) as BackendSrv; +}; + +const expectDataFrameWithValues = ({ time, values }: { time: number[]; values: number[] }): DataFrame => { + return { + fields: [ + { + config: {}, + entities: {}, + name: 'time', + state: null, + type: FieldType.time, + values: new ArrayVector(time), + } as Field, + { + config: {}, + entities: {}, + name: 'value', + state: null, + type: FieldType.number, + values: new ArrayVector(values), + } as Field, + ], + length: values.length, + }; +}; + +const createDataFrameJSON = (values: number[]): DataFrameJSON => { + const startTime = 1620051602238; + const timeValues = values.map((_, index) => startTime + (index + 1) * 10000); + + return { + schema: { + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'value', type: FieldType.number }, + ], + }, + data: { + values: [timeValues, values], + }, + }; +}; + +const createQuery = (refId: string): GrafanaQuery => { + return { + refId, + queryType: '', + datasourceUid: '', + model: { refId }, + relativeTimeRange: getDefaultRelativeTimeRange(), + }; +}; diff --git a/public/app/features/alerting/state/AlertingQueryRunner.ts b/public/app/features/alerting/state/AlertingQueryRunner.ts new file mode 100644 index 0000000..ff8be17 --- /dev/null +++ b/public/app/features/alerting/state/AlertingQueryRunner.ts @@ -0,0 +1,186 @@ +import { merge, Observable, of, OperatorFunction, ReplaySubject, timer, Unsubscribable } from 'rxjs'; +import { catchError, map, mapTo, share, takeUntil } from 'rxjs/operators'; +import { v4 as uuidv4 } from 'uuid'; +import { + dataFrameFromJSON, + DataFrameJSON, + getDefaultTimeRange, + LoadingState, + PanelData, + rangeUtil, + TimeRange, +} from '@grafana/data'; +import { FetchResponse, toDataQueryError } from '@grafana/runtime'; +import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; +import { preProcessPanelData } from 'app/features/query/state/runRequest'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { getTimeRangeForExpression } from '../unified/utils/timeRange'; +import { isExpressionQuery } from 'app/features/expressions/guards'; +import { setStructureRevision } from 'app/features/query/state/processing/revision'; +import { cancelNetworkRequestsOnUnsubscribe } from 'app/features/query/state/processing/canceler'; + +export interface AlertingQueryResult { + frames: DataFrameJSON[]; +} + +export interface AlertingQueryResponse { + results: Record; +} +export class AlertingQueryRunner { + private subject: ReplaySubject>; + private subscription?: Unsubscribable; + private lastResult: Record; + + constructor(private backendSrv = getBackendSrv()) { + this.subject = new ReplaySubject(1); + this.lastResult = {}; + } + + get(): Observable> { + return this.subject.asObservable(); + } + + run(queries: GrafanaQuery[]) { + if (queries.length === 0) { + const empty = initialState(queries, LoadingState.Done); + return this.subject.next(empty); + } + + this.subscription = runRequest(this.backendSrv, queries).subscribe({ + next: (dataPerQuery) => { + const nextResult = applyChange(dataPerQuery, (refId, data) => { + const previous = this.lastResult[refId]; + const preProcessed = preProcessPanelData(data, previous); + return setStructureRevision(preProcessed, previous); + }); + + this.lastResult = nextResult; + this.subject.next(this.lastResult); + }, + error: (error: Error) => { + this.lastResult = mapErrorToPanelData(this.lastResult, error); + this.subject.next(this.lastResult); + }, + }); + } + + cancel() { + if (!this.subscription) { + return; + } + this.subscription.unsubscribe(); + + let requestIsRunning = false; + + const nextResult = applyChange(this.lastResult, (refId, data) => { + if (data.state === LoadingState.Loading) { + requestIsRunning = true; + } + + return { + ...data, + state: LoadingState.Done, + }; + }); + + if (requestIsRunning) { + this.subject.next(nextResult); + } + } + + destroy() { + if (this.subject) { + this.subject.complete(); + } + this.cancel(); + } +} + +const runRequest = (backendSrv: BackendSrv, queries: GrafanaQuery[]): Observable> => { + const initial = initialState(queries, LoadingState.Loading); + const request = { + data: { data: queries }, + url: '/api/v1/eval', + method: 'POST', + requestId: uuidv4(), + }; + + const runningRequest = backendSrv.fetch(request).pipe( + mapToPanelData(initial), + catchError((error) => of(mapErrorToPanelData(initial, error))), + cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId), + share() + ); + + return merge(timer(200).pipe(mapTo(initial), takeUntil(runningRequest)), runningRequest); +}; + +const initialState = (queries: GrafanaQuery[], state: LoadingState): Record => { + return queries.reduce((dataByQuery: Record, query) => { + dataByQuery[query.refId] = { + state, + series: [], + timeRange: getTimeRange(query, queries), + }; + + return dataByQuery; + }, {}); +}; + +const getTimeRange = (query: GrafanaQuery, queries: GrafanaQuery[]): TimeRange => { + if (isExpressionQuery(query.model)) { + const relative = getTimeRangeForExpression(query.model, queries); + return rangeUtil.relativeToTimeRange(relative); + } + + if (!query.relativeTimeRange) { + console.warn(`Query with refId: ${query.refId} did not have any relative time range, using default.`); + return getDefaultTimeRange(); + } + + return rangeUtil.relativeToTimeRange(query.relativeTimeRange); +}; + +const mapToPanelData = ( + dataByQuery: Record +): OperatorFunction, Record> => { + return map((response) => { + const { data } = response; + const results: Record = {}; + + for (const [refId, result] of Object.entries(data.results)) { + results[refId] = { + timeRange: dataByQuery[refId].timeRange, + state: LoadingState.Done, + series: result.frames.map(dataFrameFromJSON), + }; + } + + return results; + }); +}; + +const mapErrorToPanelData = (lastResult: Record, error: Error): Record => { + const queryError = toDataQueryError(error); + + return applyChange(lastResult, (refId, data) => { + return { + ...data, + state: LoadingState.Error, + error: queryError, + }; + }); +}; + +const applyChange = ( + initial: Record, + change: (refId: string, data: PanelData) => PanelData +): Record => { + const nextResult: Record = {}; + + for (const [refId, data] of Object.entries(initial)) { + nextResult[refId] = change(refId, data); + } + + return nextResult; +}; diff --git a/public/app/features/alerting/state/ThresholdMapper.test.ts b/public/app/features/alerting/state/ThresholdMapper.test.ts new file mode 100644 index 0000000..df70c65 --- /dev/null +++ b/public/app/features/alerting/state/ThresholdMapper.test.ts @@ -0,0 +1,138 @@ +import { hiddenReducerTypes, ThresholdMapper } from './ThresholdMapper'; +import alertDef from './alertDef'; + +const visibleReducerTypes = alertDef.reducerTypes + .filter(({ value }) => hiddenReducerTypes.indexOf(value) === -1) + .map(({ value }) => value); + +describe('ThresholdMapper', () => { + describe('with greater than evaluator', () => { + it('can map query conditions to thresholds', () => { + const panel: any = { + type: 'graph', + options: { alertThresholds: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'gt', params: [100] }, + }, + ], + }, + }; + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + expect(updated).toBe(true); + expect(panel.thresholds[0].op).toBe('gt'); + expect(panel.thresholds[0].value).toBe(100); + }); + }); + + describe('with outside range evaluator', () => { + it('can map query conditions to thresholds', () => { + const panel: any = { + type: 'graph', + options: { alertThresholds: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'outside_range', params: [100, 200] }, + }, + ], + }, + }; + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + expect(updated).toBe(true); + expect(panel.thresholds[0].op).toBe('lt'); + expect(panel.thresholds[0].value).toBe(100); + + expect(panel.thresholds[1].op).toBe('gt'); + expect(panel.thresholds[1].value).toBe(200); + }); + }); + + describe('with inside range evaluator', () => { + it('can map query conditions to thresholds', () => { + const panel: any = { + type: 'graph', + options: { alertThresholds: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'within_range', params: [100, 200] }, + }, + ], + }, + }; + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + expect(updated).toBe(true); + expect(panel.thresholds[0].op).toBe('gt'); + expect(panel.thresholds[0].value).toBe(100); + + expect(panel.thresholds[1].op).toBe('lt'); + expect(panel.thresholds[1].value).toBe(200); + }); + }); + + visibleReducerTypes.forEach((type) => { + describe(`with {${type}} reducer`, () => { + it('visible should be true', () => { + const panel = getPanel({ reducerType: type }); + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + + expect(updated).toBe(true); + expect(panel.thresholds[0]).toEqual({ + value: 100, + op: 'gt', + fill: true, + line: true, + colorMode: 'critical', + visible: true, + }); + }); + }); + }); + + hiddenReducerTypes.forEach((type) => { + describe(`with {${type}} reducer`, () => { + it('visible should be false', () => { + const panel = getPanel({ reducerType: type }); + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + + expect(updated).toBe(true); + expect(panel.thresholds[0]).toEqual({ + value: 100, + op: 'gt', + fill: true, + line: true, + colorMode: 'critical', + visible: false, + }); + }); + }); + }); +}); + +function getPanel({ reducerType }: { reducerType?: string } = {}) { + const panel: any = { + type: 'graph', + options: { alertThreshold: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'gt', params: [100] }, + reducer: { type: reducerType }, + }, + ], + }, + }; + + return panel; +} diff --git a/public/app/features/alerting/state/ThresholdMapper.ts b/public/app/features/alerting/state/ThresholdMapper.ts new file mode 100644 index 0000000..e382919 --- /dev/null +++ b/public/app/features/alerting/state/ThresholdMapper.ts @@ -0,0 +1,71 @@ +import { PanelModel } from 'app/features/dashboard/state'; + +export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs']; +export class ThresholdMapper { + static alertToGraphThresholds(panel: PanelModel) { + if (!panel.alert) { + return false; // no update when no alerts + } + + for (let i = 0; i < panel.alert.conditions.length; i++) { + const condition = panel.alert.conditions[i]; + if (condition.type !== 'query') { + continue; + } + + const evaluator = condition.evaluator; + const thresholds: any[] = (panel.thresholds = []); + const visible = hiddenReducerTypes.indexOf(condition.reducer?.type) === -1; + + switch (evaluator.type) { + case 'gt': { + const value = evaluator.params[0]; + thresholds.push({ value: value, op: 'gt', visible }); + break; + } + case 'lt': { + const value = evaluator.params[0]; + thresholds.push({ value: value, op: 'lt', visible }); + break; + } + case 'outside_range': { + const value1 = evaluator.params[0]; + const value2 = evaluator.params[1]; + + if (value1 > value2) { + thresholds.push({ value: value1, op: 'gt', visible }); + thresholds.push({ value: value2, op: 'lt', visible }); + } else { + thresholds.push({ value: value1, op: 'lt', visible }); + thresholds.push({ value: value2, op: 'gt', visible }); + } + + break; + } + case 'within_range': { + const value1 = evaluator.params[0]; + const value2 = evaluator.params[1]; + + if (value1 > value2) { + thresholds.push({ value: value1, op: 'lt', visible }); + thresholds.push({ value: value2, op: 'gt', visible }); + } else { + thresholds.push({ value: value1, op: 'gt', visible }); + thresholds.push({ value: value2, op: 'lt', visible }); + } + break; + } + } + break; + } + + for (const t of panel.thresholds) { + t.fill = panel.options.alertThreshold; + t.line = panel.options.alertThreshold; + t.colorMode = 'critical'; + } + + const updated = true; + return updated; + } +} diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts new file mode 100644 index 0000000..c3871f0 --- /dev/null +++ b/public/app/features/alerting/state/actions.ts @@ -0,0 +1,264 @@ +import { + AppEvents, + applyFieldOverrides, + dataFrameFromJSON, + DataFrameJSON, + DataQuery, + DataSourceApi, +} from '@grafana/data'; +import { config, getBackendSrv, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { appEvents } from 'app/core/core'; +import store from 'app/core/store'; +import { + ALERT_DEFINITION_UI_STATE_STORAGE_KEY, + cleanUpState, + loadAlertRules, + loadedAlertRules, + notificationChannelLoaded, + setAlertDefinition, + setAlertDefinitions, + setInstanceData, + setNotificationChannels, + setUiState, + updateAlertDefinitionOptions, +} from './reducers'; +import { + AlertDefinition, + AlertDefinitionState, + AlertDefinitionUiState, + AlertRuleDTO, + NotifierDTO, + QueryGroupDataSource, + QueryGroupOptions, + ThunkResult, +} from 'app/types'; +import { ExpressionDatasourceID } from '../../expressions/ExpressionDatasource'; +import { isExpressionQuery } from 'app/features/expressions/guards'; + +export function getAlertRulesAsync(options: { state: string }): ThunkResult { + return async (dispatch) => { + dispatch(loadAlertRules()); + const rules: AlertRuleDTO[] = await getBackendSrv().get('/api/alerts', options); + + if (config.featureToggles.ngalert) { + const ngAlertDefinitions = await getBackendSrv().get('/api/alert-definitions'); + dispatch(setAlertDefinitions(ngAlertDefinitions.results)); + } + + dispatch(loadedAlertRules(rules)); + }; +} + +export function togglePauseAlertRule(id: number, options: { paused: boolean }): ThunkResult { + return async (dispatch) => { + await getBackendSrv().post(`/api/alerts/${id}/pause`, options); + const stateFilter = locationService.getSearchObject().state || 'all'; + dispatch(getAlertRulesAsync({ state: stateFilter.toString() })); + }; +} + +export function createNotificationChannel(data: any): ThunkResult { + return async (dispatch) => { + try { + await getBackendSrv().post(`/api/alert-notifications`, data); + appEvents.emit(AppEvents.alertSuccess, ['Notification created']); + locationService.push('/alerting/notifications'); + } catch (error) { + appEvents.emit(AppEvents.alertError, [error.data.error]); + } + }; +} + +export function updateNotificationChannel(data: any): ThunkResult { + return async (dispatch) => { + try { + await getBackendSrv().put(`/api/alert-notifications/${data.id}`, data); + appEvents.emit(AppEvents.alertSuccess, ['Notification updated']); + } catch (error) { + appEvents.emit(AppEvents.alertError, [error.data.error]); + } + }; +} + +export function testNotificationChannel(data: any): ThunkResult { + return async (dispatch, getState) => { + const channel = getState().notificationChannel.notificationChannel; + await getBackendSrv().post('/api/alert-notifications/test', { id: channel.id, ...data }); + }; +} + +export function loadNotificationTypes(): ThunkResult { + return async (dispatch) => { + const alertNotifiers: NotifierDTO[] = await getBackendSrv().get(`/api/alert-notifiers`); + + const notificationTypes = alertNotifiers.sort((o1, o2) => { + if (o1.name > o2.name) { + return 1; + } + return -1; + }); + + dispatch(setNotificationChannels(notificationTypes)); + }; +} + +export function loadNotificationChannel(id: number): ThunkResult { + return async (dispatch) => { + await dispatch(loadNotificationTypes()); + const notificationChannel = await getBackendSrv().get(`/api/alert-notifications/${id}`); + dispatch(notificationChannelLoaded(notificationChannel)); + }; +} + +export function getAlertDefinition(id: string): ThunkResult { + return async (dispatch) => { + const alertDefinition = await getBackendSrv().get(`/api/alert-definitions/${id}`); + dispatch(setAlertDefinition(alertDefinition)); + }; +} + +export function createAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const alertDefinition = await buildAlertDefinition(getStore().alertDefinition); + await getBackendSrv().post(`/api/alert-definitions`, alertDefinition); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition created']); + locationService.push('/alerting/ng/list'); + }; +} + +export function updateAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const alertDefinition = await buildAlertDefinition(getStore().alertDefinition); + + const updatedAlertDefinition = await getBackendSrv().put( + `/api/alert-definitions/${alertDefinition.uid}`, + alertDefinition + ); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition updated']); + dispatch(setAlertDefinition(updatedAlertDefinition)); + }; +} + +export function updateAlertDefinitionUiState(uiState: Partial): ThunkResult { + return (dispatch, getStore) => { + const nextState = { ...getStore().alertDefinition.uiState, ...uiState }; + dispatch(setUiState(nextState)); + + try { + store.setObject(ALERT_DEFINITION_UI_STATE_STORAGE_KEY, nextState); + } catch (error) { + console.error(error); + } + }; +} + +export function updateAlertDefinitionOption(alertDefinition: Partial): ThunkResult { + return (dispatch) => { + dispatch(updateAlertDefinitionOptions(alertDefinition)); + }; +} + +export function evaluateAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const { alertDefinition } = getStore().alertDefinition; + + const response: { instances: DataFrameJSON[] } = await getBackendSrv().get( + `/api/alert-definitions/eval/${alertDefinition.uid}` + ); + + const handledResponse = handleJSONResponse(response.instances); + + dispatch(setInstanceData(handledResponse)); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition tested successfully']); + }; +} + +export function evaluateNotSavedAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const { alertDefinition } = getStore().alertDefinition; + const defaultDataSource = await getDataSourceSrv().get(null); + + const response: { instances: DataFrameJSON[] } = await getBackendSrv().post('/api/alert-definitions/eval', { + condition: alertDefinition.condition, + data: buildDataQueryModel({} as QueryGroupOptions, defaultDataSource), + }); + + const handledResponse = handleJSONResponse(response.instances); + dispatch(setInstanceData(handledResponse)); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition tested successfully']); + }; +} + +export function cleanUpDefinitionState(): ThunkResult { + return (dispatch) => { + dispatch(cleanUpState(undefined)); + }; +} + +async function buildAlertDefinition(state: AlertDefinitionState) { + const queryOptions = {} as QueryGroupOptions; + const currentAlertDefinition = state.alertDefinition; + const defaultDataSource = await getDataSourceSrv().get(null); + + return { + ...currentAlertDefinition, + data: buildDataQueryModel(queryOptions, defaultDataSource), + }; +} + +function handleJSONResponse(frames: DataFrameJSON[]) { + const dataFrames = frames.map((instance) => { + return dataFrameFromJSON(instance); + }); + + return applyFieldOverrides({ + data: dataFrames, + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (value: any) => value, + theme: config.theme2, + }); +} + +function buildDataQueryModel(queryOptions: QueryGroupOptions, defaultDataSource: DataSourceApi) { + return queryOptions.queries.map((query: DataQuery) => { + if (isExpressionQuery(query)) { + const dataSource: QueryGroupDataSource = { + name: ExpressionDatasourceID, + uid: ExpressionDatasourceID, + }; + + return { + model: { + ...query, + type: query.type, + datasource: dataSource.name, + datasourceUid: dataSource.uid, + }, + refId: query.refId, + }; + } + + const dataSourceSetting = getDataSourceSrv().getInstanceSettings(query.datasource); + const dataSource: QueryGroupDataSource = { + name: dataSourceSetting?.name ?? defaultDataSource.name, + uid: dataSourceSetting?.uid ?? defaultDataSource.uid, + }; + + return { + model: { + ...query, + type: query.queryType, + datasource: dataSource.name, + datasourceUid: dataSource.uid, + }, + refId: query.refId, + relativeTimeRange: { + from: 600, + to: 0, + }, + }; + }); +} diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts new file mode 100644 index 0000000..dcfb434 --- /dev/null +++ b/public/app/features/alerting/state/alertDef.ts @@ -0,0 +1,179 @@ +import { isArray, reduce } from 'lodash'; +import { QueryPartDef, QueryPart } from 'app/core/components/query_part/query_part'; + +const alertQueryDef = new QueryPartDef({ + type: 'query', + params: [ + { name: 'queryRefId', type: 'string', dynamicLookup: true }, + { + name: 'from', + type: 'string', + options: ['10s', '1m', '5m', '10m', '15m', '1h', '2h', '6h', '12h', '24h', '48h'], + }, + { name: 'to', type: 'string', options: ['now', 'now-1m', 'now-5m', 'now-10m', 'now-1h'] }, + ], + defaultParams: ['#A', '15m', 'now', 'avg'], +}); + +const conditionTypes = [{ text: 'Query', value: 'query' }]; + +const alertStateSortScore = { + alerting: 1, + no_data: 2, + pending: 3, + ok: 4, + paused: 5, +}; + +export enum EvalFunction { + 'IsAbove' = 'gt', + 'IsBelow' = 'lt', + 'IsOutsideRange' = 'outside_range', + 'IsWithinRange' = 'within_range', + 'HasNoValue' = 'no_value', +} + +const evalFunctions = [ + { value: EvalFunction.IsAbove, text: 'IS ABOVE' }, + { value: EvalFunction.IsBelow, text: 'IS BELOW' }, + { value: EvalFunction.IsOutsideRange, text: 'IS OUTSIDE RANGE' }, + { value: EvalFunction.IsWithinRange, text: 'IS WITHIN RANGE' }, + { value: EvalFunction.HasNoValue, text: 'HAS NO VALUE' }, +]; + +const evalOperators = [ + { text: 'OR', value: 'or' }, + { text: 'AND', value: 'and' }, +]; + +const reducerTypes = [ + { text: 'avg()', value: 'avg' }, + { text: 'min()', value: 'min' }, + { text: 'max()', value: 'max' }, + { text: 'sum()', value: 'sum' }, + { text: 'count()', value: 'count' }, + { text: 'last()', value: 'last' }, + { text: 'median()', value: 'median' }, + { text: 'diff()', value: 'diff' }, + { text: 'diff_abs()', value: 'diff_abs' }, + { text: 'percent_diff()', value: 'percent_diff' }, + { text: 'percent_diff_abs()', value: 'percent_diff_abs' }, + { text: 'count_non_null()', value: 'count_non_null' }, +]; + +const noDataModes = [ + { text: 'Alerting', value: 'alerting' }, + { text: 'No Data', value: 'no_data' }, + { text: 'Keep Last State', value: 'keep_state' }, + { text: 'Ok', value: 'ok' }, +]; + +const executionErrorModes = [ + { text: 'Alerting', value: 'alerting' }, + { text: 'Keep Last State', value: 'keep_state' }, +]; + +function createReducerPart(model: any) { + const def = new QueryPartDef({ type: model.type, defaultParams: [] }); + return new QueryPart(model, def); +} + +function getStateDisplayModel(state: string) { + switch (state) { + case 'ok': { + return { + text: 'OK', + iconClass: 'heart', + stateClass: 'alert-state-ok', + }; + } + case 'alerting': { + return { + text: 'ALERTING', + iconClass: 'heart-break', + stateClass: 'alert-state-critical', + }; + } + case 'no_data': { + return { + text: 'NO DATA', + iconClass: 'question-circle', + stateClass: 'alert-state-warning', + }; + } + case 'paused': { + return { + text: 'PAUSED', + iconClass: 'pause', + stateClass: 'alert-state-paused', + }; + } + case 'pending': { + return { + text: 'PENDING', + iconClass: 'exclamation-triangle', + stateClass: 'alert-state-warning', + }; + } + case 'unknown': { + return { + text: 'UNKNOWN', + iconClass: 'question-circle', + stateClass: 'alert-state-paused', + }; + } + } + + throw { message: 'Unknown alert state' }; +} + +function joinEvalMatches(matches: any, separator: string) { + return reduce( + matches, + (res, ev) => { + if (ev.metric !== undefined && ev.value !== undefined) { + res.push(ev.metric + '=' + ev.value); + } + + // For backwards compatibility . Should be be able to remove this after ~2017-06-01 + if (ev.Metric !== undefined && ev.Value !== undefined) { + res.push(ev.Metric + '=' + ev.Value); + } + + return res; + }, + [] as string[] + ).join(separator); +} + +function getAlertAnnotationInfo(ah: any) { + // backward compatibility, can be removed in grafana 5.x + // old way stored evalMatches in data property directly, + // new way stores it in evalMatches property on new data object + + if (isArray(ah.data)) { + return joinEvalMatches(ah.data, ', '); + } else if (isArray(ah.data.evalMatches)) { + return joinEvalMatches(ah.data.evalMatches, ', '); + } + + if (ah.data.error) { + return 'Error: ' + ah.data.error; + } + + return ''; +} + +export default { + alertQueryDef: alertQueryDef, + getStateDisplayModel: getStateDisplayModel, + conditionTypes: conditionTypes, + evalFunctions: evalFunctions, + evalOperators: evalOperators, + noDataModes: noDataModes, + executionErrorModes: executionErrorModes, + reducerTypes: reducerTypes, + createReducerPart: createReducerPart, + getAlertAnnotationInfo: getAlertAnnotationInfo, + alertStateSortScore: alertStateSortScore, +}; diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts new file mode 100644 index 0000000..13b9acc --- /dev/null +++ b/public/app/features/alerting/state/reducers.test.ts @@ -0,0 +1,423 @@ +import { dateTime } from '@grafana/data'; +import { + alertRulesReducer, + initialChannelState, + initialState, + loadAlertRules, + loadedAlertRules, + notificationChannelReducer, + setSearchQuery, + notificationChannelLoaded, +} from './reducers'; +import { AlertRuleDTO, AlertRulesState, NotificationChannelState, NotifierDTO } from 'app/types'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; + +describe('Alert rules', () => { + const realDateNow = Date.now.bind(global.Date); + const anchorUnix = dateTime('2019-09-04T10:01:01+02:00').valueOf(); + const dateNowStub = jest.fn(() => anchorUnix); + global.Date.now = dateNowStub; + + const newStateDate = dateTime().subtract(1, 'y'); + const newStateDateFormatted = newStateDate.format('YYYY-MM-DD'); + const newStateDateAge = newStateDate.fromNow(true); + const payload: AlertRuleDTO[] = [ + { + id: 2, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 4, + name: 'TestData - Always Alerting', + state: 'alerting', + newStateDate: `${newStateDateFormatted}T10:00:30+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: { evalMatches: [{ metric: 'A-series', tags: null, value: 215 }] }, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 1, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Always OK', + state: 'ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 3, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - ok', + state: 'ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 4, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Paused', + state: 'paused', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 5, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Ok', + state: 'ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: { + noData: true, + }, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ]; + + afterAll(() => { + global.Date.now = realDateNow; + }); + + describe('when loadAlertRules is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState }) + .whenActionIsDispatched(loadAlertRules()) + .thenStateShouldEqual({ ...initialState, isLoading: true }); + }); + }); + + describe('when setSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState }) + .whenActionIsDispatched(setSearchQuery('query')) + .thenStateShouldEqual({ ...initialState, searchQuery: 'query' }); + }); + }); + + describe('when loadedAlertRules is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState, isLoading: true }) + .whenActionIsDispatched(loadedAlertRules(payload)) + .thenStateShouldEqual({ + ...initialState, + isLoading: false, + items: [ + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: { + evalMatches: [ + { + metric: 'A-series', + tags: null, + value: 215, + }, + ], + }, + evalDate: '0001-01-01T00:00:00Z', + executionError: '', + id: 2, + name: 'TestData - Always Alerting', + newStateDate: `${newStateDateFormatted}T10:00:30+02:00`, + panelId: 4, + state: 'alerting', + stateAge: newStateDateAge, + stateClass: 'alert-state-critical', + stateIcon: 'heart-break', + stateText: 'ALERTING', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: '', + id: 1, + name: 'TestData - Always OK', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'ok', + stateAge: newStateDateAge, + stateClass: 'alert-state-ok', + stateIcon: 'heart', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 3, + info: 'Execution Error: error', + name: 'TestData - ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'ok', + stateAge: newStateDateAge, + stateClass: 'alert-state-ok', + stateIcon: 'heart', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 4, + name: 'TestData - Paused', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'paused', + stateAge: newStateDateAge, + stateClass: 'alert-state-paused', + stateIcon: 'pause', + stateText: 'PAUSED', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: { + noData: true, + }, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 5, + info: 'Query returned no data', + name: 'TestData - Ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'ok', + stateAge: newStateDateAge, + stateClass: 'alert-state-ok', + stateIcon: 'heart', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ], + }); + }); + }); +}); + +describe('Notification channel', () => { + const notifiers: NotifierDTO[] = [ + { + type: 'webhook', + name: 'webhook', + heading: 'Webhook settings', + description: 'Sends HTTP POST request to a URL', + info: '', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Url', + description: '', + placeholder: '', + propertyName: 'url', + showWhen: { field: '', is: '' }, + required: true, + validationRule: '', + secure: false, + }, + { + element: 'select', + inputType: '', + label: 'Http Method', + description: '', + placeholder: '', + propertyName: 'httpMethod', + selectOptions: [ + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + ], + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'text', + label: 'Username', + description: '', + placeholder: '', + propertyName: 'username', + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'password', + label: 'Password', + description: '', + placeholder: '', + propertyName: 'password', + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: true, + }, + ], + }, + ]; + + describe('Load notification channel', () => { + it('should migrate non secure settings to secure fields', () => { + const payload = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + settings: { + autoResolve: true, + httpMethod: 'POST', + password: 'asdf', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + const expected = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + secureSettings: { + password: 'asdf', + }, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + reducerTester() + .givenReducer(notificationChannelReducer, { ...initialChannelState, notifiers: notifiers }) + .whenActionIsDispatched(notificationChannelLoaded(payload)) + .thenStateShouldEqual({ + ...initialChannelState, + notifiers: notifiers, + notificationChannel: expected, + }); + }); + + it('should handle already secure field', () => { + const payload = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + secureFields: { + password: true, + }, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + const expected = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + secureFields: { + password: true, + }, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + reducerTester() + .givenReducer(notificationChannelReducer, { ...initialChannelState, notifiers: notifiers }) + .whenActionIsDispatched(notificationChannelLoaded(payload)) + .thenStateShouldEqual({ + ...initialChannelState, + notifiers: notifiers, + notificationChannel: expected, + }); + }); + }); +}); diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts new file mode 100644 index 0000000..c44e40d --- /dev/null +++ b/public/app/features/alerting/state/reducers.ts @@ -0,0 +1,240 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { DataFrame, dateTime } from '@grafana/data'; +import alertDef from './alertDef'; +import { + AlertDefinition, + AlertDefinitionDTO, + AlertDefinitionState, + AlertDefinitionUiState, + AlertRule, + AlertRuleDTO, + AlertRulesState, + NotificationChannelOption, + NotificationChannelState, + NotifierDTO, +} from 'app/types'; +import store from 'app/core/store'; +import unifiedAlertingReducer from '../unified/state/reducers'; + +export const ALERT_DEFINITION_UI_STATE_STORAGE_KEY = 'grafana.alerting.alertDefinition.ui'; +const DEFAULT_ALERT_DEFINITION_UI_STATE: AlertDefinitionUiState = { rightPaneSize: 400, topPaneSize: 0.45 }; + +export const initialState: AlertRulesState = { + items: [], + searchQuery: '', + isLoading: false, +}; + +export const initialChannelState: NotificationChannelState = { + notificationChannelTypes: [], + notificationChannel: {}, + notifiers: [], +}; + +export const initialAlertDefinitionState: AlertDefinitionState = { + alertDefinition: { + id: 0, + uid: '', + title: '', + description: '', + condition: '', + data: [], + intervalSeconds: 60, + }, + uiState: { ...store.getObject(ALERT_DEFINITION_UI_STATE_STORAGE_KEY, DEFAULT_ALERT_DEFINITION_UI_STATE) }, + data: [], + alertDefinitions: [] as AlertDefinition[], + /* These are functions as they are mutated later on and redux toolkit will Object.freeze state so + * we need to store these using functions instead */ + getInstances: () => [] as DataFrame[], +}; + +function convertToAlertRule(dto: AlertRuleDTO, state: string): AlertRule { + const stateModel = alertDef.getStateDisplayModel(state); + + const rule: AlertRule = { + ...dto, + stateText: stateModel.text, + stateIcon: stateModel.iconClass, + stateClass: stateModel.stateClass, + stateAge: dateTime(dto.newStateDate).fromNow(true), + }; + + if (rule.state !== 'paused') { + if (rule.executionError) { + rule.info = 'Execution Error: ' + rule.executionError; + } + if (rule.evalData && rule.evalData.noData) { + rule.info = 'Query returned no data'; + } + } + + return rule; +} + +const alertRulesSlice = createSlice({ + name: 'alertRules', + initialState, + reducers: { + loadAlertRules: (state) => { + return { ...state, isLoading: true }; + }, + loadedAlertRules: (state, action: PayloadAction): AlertRulesState => { + const alertRules: AlertRuleDTO[] = action.payload; + + const alertRulesViewModel: AlertRule[] = alertRules.map((rule) => { + return convertToAlertRule(rule, rule.state); + }); + + return { ...state, items: alertRulesViewModel, isLoading: false }; + }, + setSearchQuery: (state, action: PayloadAction): AlertRulesState => { + return { ...state, searchQuery: action.payload }; + }, + }, +}); + +const notificationChannelSlice = createSlice({ + name: 'notificationChannel', + initialState: initialChannelState, + reducers: { + setNotificationChannels: (state, action: PayloadAction): NotificationChannelState => { + return { + ...state, + notificationChannelTypes: transformNotifiers(action.payload), + notifiers: action.payload, + }; + }, + notificationChannelLoaded: (state, action: PayloadAction): NotificationChannelState => { + const notificationChannel = action.payload; + const selectedType: NotifierDTO = state.notifiers.find((t) => t.type === notificationChannel.type)!; + const secureChannelOptions = selectedType.options.filter((o: NotificationChannelOption) => o.secure); + /* + If any secure field is in plain text we need to migrate it to use secure field instead. + */ + if ( + secureChannelOptions.length > 0 && + secureChannelOptions.some((o: NotificationChannelOption) => { + return notificationChannel.settings[o.propertyName] !== ''; + }) + ) { + return migrateSecureFields(state, action.payload, secureChannelOptions); + } + + return { ...state, notificationChannel: notificationChannel }; + }, + resetSecureField: (state, action: PayloadAction): NotificationChannelState => { + return { + ...state, + notificationChannel: { + ...state.notificationChannel, + secureFields: { ...state.notificationChannel.secureFields, [action.payload]: false }, + }, + }; + }, + }, +}); + +const alertDefinitionSlice = createSlice({ + name: 'alertDefinition', + initialState: initialAlertDefinitionState, + reducers: { + setAlertDefinition: (state: AlertDefinitionState, action: PayloadAction) => { + state.alertDefinition.title = action.payload.title; + state.alertDefinition.id = action.payload.id; + state.alertDefinition.uid = action.payload.uid; + state.alertDefinition.condition = action.payload.condition; + state.alertDefinition.intervalSeconds = action.payload.intervalSeconds; + state.alertDefinition.data = action.payload.data; + state.alertDefinition.description = action.payload.description; + }, + updateAlertDefinitionOptions: (state: AlertDefinitionState, action: PayloadAction>) => { + state.alertDefinition = { ...state.alertDefinition, ...action.payload }; + }, + setUiState: (state: AlertDefinitionState, action: PayloadAction) => { + state.uiState = { ...state.uiState, ...action.payload }; + }, + setAlertDefinitions: (state: AlertDefinitionState, action: PayloadAction) => { + state.alertDefinitions = action.payload; + }, + setInstanceData: (state: AlertDefinitionState, action: PayloadAction) => { + state.getInstances = () => action.payload; + }, + cleanUpState: (state: AlertDefinitionState, action: PayloadAction) => { + state.alertDefinitions = initialAlertDefinitionState.alertDefinitions; + state.alertDefinition = initialAlertDefinitionState.alertDefinition; + state.data = initialAlertDefinitionState.data; + state.getInstances = initialAlertDefinitionState.getInstances; + state.uiState = initialAlertDefinitionState.uiState; + }, + }, +}); + +export const { loadAlertRules, loadedAlertRules, setSearchQuery } = alertRulesSlice.actions; + +export const { + setNotificationChannels, + notificationChannelLoaded, + resetSecureField, +} = notificationChannelSlice.actions; + +export const { + setUiState, + updateAlertDefinitionOptions, + setAlertDefinitions, + setAlertDefinition, + setInstanceData, + cleanUpState, +} = alertDefinitionSlice.actions; + +export const alertRulesReducer = alertRulesSlice.reducer; +export const notificationChannelReducer = notificationChannelSlice.reducer; +export const alertDefinitionsReducer = alertDefinitionSlice.reducer; + +export default { + alertRules: alertRulesReducer, + notificationChannel: notificationChannelReducer, + alertDefinition: alertDefinitionsReducer, + unifiedAlerting: unifiedAlertingReducer, +}; + +function migrateSecureFields( + state: NotificationChannelState, + notificationChannel: any, + secureChannelOptions: NotificationChannelOption[] +) { + const cleanedSettings: { [key: string]: string } = {}; + const secureSettings: { [key: string]: string } = {}; + + secureChannelOptions.forEach((option) => { + secureSettings[option.propertyName] = notificationChannel.settings[option.propertyName]; + cleanedSettings[option.propertyName] = ''; + }); + + return { + ...state, + notificationChannel: { + ...notificationChannel, + settings: { ...notificationChannel.settings, ...cleanedSettings }, + secureSettings: { ...secureSettings }, + }, + }; +} + +function transformNotifiers(notifiers: NotifierDTO[]) { + return notifiers + .map((option: NotifierDTO) => { + return { + value: option.type, + label: option.name, + ...option, + typeName: option.type, + }; + }) + .sort((o1, o2) => { + if (o1.name > o2.name) { + return 1; + } + return -1; + }); +} diff --git a/public/app/features/alerting/state/selectors.test.ts b/public/app/features/alerting/state/selectors.test.ts new file mode 100644 index 0000000..a38bb79 --- /dev/null +++ b/public/app/features/alerting/state/selectors.test.ts @@ -0,0 +1,98 @@ +import { getSearchQuery, getAlertRuleItems } from './selectors'; + +describe('Get search query', () => { + it('should get search query', () => { + const state = { searchQuery: 'dashboard' }; + const result = getSearchQuery(state as any); + + expect(result).toEqual(state.searchQuery); + }); +}); + +describe('Get alert rule items', () => { + it('should get alert rule items', () => { + const state = { + alertRules: { + items: [ + { + id: 1, + dashboardId: 1, + panelId: 1, + name: '', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + ], + searchQuery: '', + }, + }; + + const result = getAlertRuleItems(state as any); + expect(result.length).toEqual(1); + }); + + it('should filter rule items based on search query', () => { + const state = { + alertRules: { + items: [ + { + id: 1, + dashboardId: 1, + panelId: 1, + name: 'dashboard', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 2, + dashboardId: 3, + panelId: 1, + name: 'dashboard2', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 3, + dashboardId: 5, + panelId: 1, + name: 'hello', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 4, + dashboardId: 7, + panelId: 1, + name: 'test', + state: '', + stateText: 'dashboard', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + ], + searchQuery: 'dashboard', + }, + }; + + const result = getAlertRuleItems(state as any); + expect(result.length).toEqual(3); + }); +}); diff --git a/public/app/features/alerting/state/selectors.ts b/public/app/features/alerting/state/selectors.ts new file mode 100644 index 0000000..3db0c20 --- /dev/null +++ b/public/app/features/alerting/state/selectors.ts @@ -0,0 +1,33 @@ +import { AlertDefinition, AlertRule, AlertRulesState, NotificationChannelState, StoreState } from 'app/types'; +import { config } from '@grafana/runtime'; + +export const getSearchQuery = (state: AlertRulesState) => state.searchQuery; + +export const getAlertRuleItems = (state: StoreState) => { + const regex = new RegExp(state.alertRules.searchQuery, 'i'); + const result: Array = []; + + result.push( + ...state.alertRules.items.filter((item) => { + return regex.test(item.name) || regex.test(item.stateText) || regex.test(item.info!); + }) + ); + + if (config.featureToggles.ngalert) { + result.push( + ...state.alertDefinition.alertDefinitions.filter((item) => { + return regex.test(item.title); + }) + ); + } + + return result; +}; + +export const getNotificationChannel = (state: NotificationChannelState, channelId: number) => { + if (state.notificationChannel.id === channelId) { + return state.notificationChannel; + } + + return null; +}; diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx new file mode 100644 index 0000000..75471ed --- /dev/null +++ b/public/app/features/alerting/unified/AmRoutes.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { render, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { Route } from 'app/plugins/datasource/alertmanager/types'; +import { configureStore } from 'app/store/configureStore'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { byTestId } from 'testing-library-selector'; +import AmRoutes from './AmRoutes'; +import { fetchAlertManagerConfig } from './api/alertmanager'; +import { mockDataSource, MockDataSourceSrv } from './mocks'; +import { getAllDataSources } from './utils/config'; +import { DataSourceType } from './utils/datasource'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +jest.mock('./api/alertmanager'); +jest.mock('./utils/config'); + +const mocks = { + getAllDataSourcesMock: typeAsJestMock(getAllDataSources), + + api: { + fetchAlertManagerConfig: typeAsJestMock(fetchAlertManagerConfig), + }, +}; + +const renderAmRoutes = () => { + const store = configureStore(); + + return render( + + + + + + ); +}; + +const dataSources = { + am: mockDataSource({ + name: 'Alert Manager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + rootReceiver: byTestId('am-routes-root-receiver'), + rootGroupBy: byTestId('am-routes-root-group-by'), + rootTimings: byTestId('am-routes-root-timings'), + row: byTestId('am-routes-row'), +}; + +describe('AmRoutes', () => { + const subroutes: Route[] = [ + { + match: { + sub1matcher1: 'sub1value1', + sub1matcher2: 'sub1value2', + }, + match_re: { + sub1matcher3: 'sub1value3', + sub1matcher4: 'sub1value4', + }, + group_by: ['sub1group1', 'sub1group2'], + receiver: 'a-receiver', + continue: true, + group_wait: '3s', + group_interval: '2m', + repeat_interval: '1s', + routes: [ + { + match: { + sub1sub1matcher1: 'sub1sub1value1', + sub1sub1matcher2: 'sub1sub1value2', + }, + match_re: { + sub1sub1matcher3: 'sub1sub1value3', + sub1sub1matcher4: 'sub1sub1value4', + }, + group_by: ['sub1sub1group1', 'sub1sub1group2'], + receiver: 'another-receiver', + }, + { + match: { + sub1sub2matcher1: 'sub1sub2value1', + sub1sub2matcher2: 'sub1sub2value2', + }, + match_re: { + sub1sub2matcher3: 'sub1sub2value3', + sub1sub2matcher4: 'sub1sub2value4', + }, + group_by: ['sub1sub2group1', 'sub1sub2group2'], + receiver: 'another-receiver', + }, + ], + }, + { + match: { + sub2matcher1: 'sub2value1', + sub2matcher2: 'sub2value2', + }, + match_re: { + sub2matcher3: 'sub2value3', + sub2matcher4: 'sub2value4', + }, + receiver: 'another-receiver', + }, + ]; + + const rootRoute: Route = { + receiver: 'default-receiver', + group_by: ['a-group', 'another-group'], + group_wait: '1s', + group_interval: '2m', + repeat_interval: '3d', + routes: subroutes, + }; + + beforeAll(() => { + mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); + + mocks.api.fetchAlertManagerConfig.mockImplementation(() => + Promise.resolve({ + alertmanager_config: { + route: rootRoute, + receivers: [ + { + name: 'default-receiver', + }, + { + name: 'a-receiver', + }, + { + name: 'another-receiver', + }, + ], + }, + template_files: {}, + }) + ); + }); + + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + }); + + afterEach(() => { + jest.resetAllMocks(); + + setDataSourceSrv(undefined as any); + }); + + it('loads and shows routes', async () => { + await renderAmRoutes(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); + + expect(ui.rootReceiver.get()).toHaveTextContent(rootRoute.receiver!); + expect(ui.rootGroupBy.get()).toHaveTextContent(rootRoute.group_by!.join(', ')); + const rootTimings = ui.rootTimings.get(); + expect(rootTimings).toHaveTextContent(rootRoute.group_wait!); + expect(rootTimings).toHaveTextContent(rootRoute.group_interval!); + expect(rootTimings).toHaveTextContent(rootRoute.repeat_interval!); + + const rows = await ui.row.findAll(); + expect(rows).toHaveLength(2); + + subroutes.forEach((route, index) => { + Object.entries({ + ...(route.match ?? {}), + ...(route.match_re ?? {}), + }).forEach(([label, value]) => { + expect(rows[index]).toHaveTextContent(`${label}=${value}`); + }); + + if (route.group_by) { + expect(rows[index]).toHaveTextContent(route.group_by.join(', ')); + } + + if (route.receiver) { + expect(rows[index]).toHaveTextContent(route.receiver); + } + }); + }); +}); diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx new file mode 100644 index 0000000..ce12516 --- /dev/null +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -0,0 +1,144 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { useDispatch } from 'react-redux'; +import { Redirect } from 'react-router-dom'; +import { Receiver } from 'app/plugins/datasource/alertmanager/types'; +import { useCleanup } from '../../../core/hooks/useCleanup'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { AmRootRoute } from './components/amroutes/AmRootRoute'; +import { AmSpecificRouting } from './components/amroutes/AmSpecificRouting'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions'; +import { AmRouteReceiver, FormAmRoute } from './types/amroutes'; +import { amRouteToFormAmRoute, formAmRouteToAmRoute, stringsToSelectableValues } from './utils/amroutes'; +import { initialAsyncRequestState } from './utils/redux'; + +const AmRoutes: FC = () => { + const dispatch = useDispatch(); + const styles = useStyles2(getStyles); + const [isRootRouteEditMode, setIsRootRouteEditMode] = useState(false); + + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + + const fetchConfig = useCallback(() => { + if (alertManagerSourceName) { + dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); + } + }, [alertManagerSourceName, dispatch]); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + const { result, loading: resultLoading, error: resultError } = + (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + + const config = result?.alertmanager_config; + const routes = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]); + + const receivers = stringsToSelectableValues( + (config?.receivers ?? []).map((receiver: Receiver) => receiver.name) + ) as AmRouteReceiver[]; + + const enterRootRouteEditMode = () => { + setIsRootRouteEditMode(true); + }; + + const exitRootRouteEditMode = () => { + setIsRootRouteEditMode(false); + }; + + useCleanup((state) => state.unifiedAlerting.saveAMConfig); + const { loading: saving, error: savingError, dispatched: savingDispatched } = useUnifiedAlertingSelector( + (state) => state.saveAMConfig + ); + + const handleSave = (data: Partial) => { + const newData = formAmRouteToAmRoute({ + ...routes, + ...data, + }); + + if (isRootRouteEditMode) { + exitRootRouteEditMode(); + } + + dispatch( + updateAlertManagerConfigAction({ + newConfig: { + ...result, + alertmanager_config: { + ...result.alertmanager_config, + route: newData, + }, + }, + oldConfig: result, + alertManagerSourceName: alertManagerSourceName!, + successMessage: 'Saved', + }) + ); + }; + + useEffect(() => { + if (savingDispatched && !saving && !savingError) { + fetchConfig(); + } + }, [fetchConfig, savingDispatched, saving, savingError]); + + if (!alertManagerSourceName) { + return ; + } + + return ( + + + {savingError && !saving && ( + + {savingError.message || 'Unknown error.'} + + )} + {resultError && !resultLoading && ( + + {resultError.message || 'Unknown error.'} + + )} + {resultLoading && } + {result && !resultLoading && !resultError && ( + <> + +
+ + + )} + + ); +}; + +export default AmRoutes; + +const getStyles = (theme: GrafanaTheme2) => ({ + break: css` + width: 100%; + height: 0; + margin-bottom: ${theme.spacing(2)}; + border-bottom: solid 1px ${theme.colors.border.medium}; + `, +}); diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx new file mode 100644 index 0000000..76b8788 --- /dev/null +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -0,0 +1,305 @@ +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import Receivers from './Receivers'; +import React from 'react'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { act, render } from '@testing-library/react'; +import { getAllDataSources } from './utils/config'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { updateAlertManagerConfig, fetchAlertManagerConfig } from './api/alertmanager'; +import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someGrafanaAlertManagerConfig } from './mocks'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { fetchNotifiers } from './api/grafana'; +import { grafanaNotifiersMock } from './mocks/grafana-notifiers'; +import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; +import userEvent from '@testing-library/user-event'; +import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; +import store from 'app/core/store'; + +jest.mock('./api/alertmanager'); +jest.mock('./api/grafana'); +jest.mock('./utils/config'); + +const mocks = { + getAllDataSources: typeAsJestMock(getAllDataSources), + + api: { + fetchConfig: typeAsJestMock(fetchAlertManagerConfig), + updateConfig: typeAsJestMock(updateAlertManagerConfig), + fetchNotifiers: typeAsJestMock(fetchNotifiers), + }, +}; + +const renderReceivers = (alertManagerSourceName?: string) => { + const store = configureStore(); + + locationService.push( + '/alerting/notifications' + + (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '') + ); + + return render( + + + + + + ); +}; + +const dataSources = { + alertManager: mockDataSource({ + name: 'CloudManager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + newContactPointButton: byRole('link', { name: /new contact point/i }), + saveContactButton: byRole('button', { name: /save contact point/i }), + newContactPointTypeButton: byRole('button', { name: /new contact point type/i }), + + receiversTable: byTestId('receivers-table'), + templatesTable: byTestId('templates-table'), + alertManagerPicker: byTestId('alertmanager-picker'), + + channelFormContainer: byTestId('item-container'), + + inputs: { + name: byLabelText('Name'), + email: { + addresses: byLabelText('Addresses'), + }, + hipchat: { + url: byLabelText('Hip Chat Url'), + apiKey: byLabelText('API Key'), + }, + slack: { + webhookURL: byLabelText(/Webhook URL/i), + }, + webhook: { + URL: byLabelText(/The endpoint to send HTTP POST requests to/i), + }, + }, +}; + +const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise => { + userEvent.click(byRole('textbox').get(selectElement)); + userEvent.click(byText(optionText).get(selectElement)); +}; + +describe('Receivers', () => { + beforeEach(() => { + jest.resetAllMocks(); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock); + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); + }); + + it('Template and receiver tables are rendered, alert manager can be selected', async () => { + mocks.api.fetchConfig.mockImplementation((name) => + Promise.resolve(name === GRAFANA_RULES_SOURCE_NAME ? someGrafanaAlertManagerConfig : someCloudAlertManagerConfig) + ); + await renderReceivers(); + + // check that by default grafana templates & receivers are fetched rendered in appropriate tables + let receiversTable = await ui.receiversTable.find(); + let templatesTable = await ui.templatesTable.find(); + let templateRows = templatesTable.querySelectorAll('tbody tr'); + expect(templateRows).toHaveLength(3); + expect(templateRows[0]).toHaveTextContent('first template'); + expect(templateRows[1]).toHaveTextContent('second template'); + expect(templateRows[2]).toHaveTextContent('third template'); + let receiverRows = receiversTable.querySelectorAll('tbody tr'); + expect(receiverRows[0]).toHaveTextContent('default'); + expect(receiverRows[1]).toHaveTextContent('critical'); + expect(receiverRows).toHaveLength(2); + + expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(1); + expect(mocks.api.fetchConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME); + expect(mocks.api.fetchNotifiers).toHaveBeenCalledTimes(1); + expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual(undefined); + + // select external cloud alertmanager, check that data is retrieved and contents are rendered as appropriate + await clickSelectOption(ui.alertManagerPicker.get(), 'CloudManager'); + await byText('cloud-receiver').find(); + expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(2); + expect(mocks.api.fetchConfig).toHaveBeenLastCalledWith('CloudManager'); + + receiversTable = await ui.receiversTable.find(); + templatesTable = await ui.templatesTable.find(); + templateRows = templatesTable.querySelectorAll('tbody tr'); + expect(templateRows[0]).toHaveTextContent('foo template'); + expect(templateRows).toHaveLength(1); + receiverRows = receiversTable.querySelectorAll('tbody tr'); + expect(receiverRows[0]).toHaveTextContent('cloud-receiver'); + expect(receiverRows).toHaveLength(1); + expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual('CloudManager'); + }); + + it('Grafana receiver can be created', async () => { + mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig); + mocks.api.updateConfig.mockResolvedValue(); + await renderReceivers(); + + // go to new contact point page + await userEvent.click(await ui.newContactPointButton.find()); + + await byRole('heading', { name: /create contact point/i }).find(); + expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/new'); + + // type in a name for the new receiver + await userEvent.type(byLabelText('Name').get(), 'my new receiver'); + + // check that default email form is rendered + await ui.inputs.name.find(); + + // select hipchat + clickSelectOption(byTestId('items.0.type').get(), 'HipChat'); + + // check that email options are gone and hipchat options appear + expect(ui.inputs.email.addresses.query()).not.toBeInTheDocument(); + + const urlInput = ui.inputs.hipchat.url.get(); + const apiKeyInput = ui.inputs.hipchat.apiKey.get(); + + await userEvent.type(urlInput, 'http://hipchat'); + await userEvent.type(apiKeyInput, 'foobarbaz'); + + // it seems react-hook-form does some async state updates after submit + await act(async () => { + await userEvent.click(ui.saveContactButton.get()); + }); + + // see that we're back to main page and proper api calls have been made + await ui.receiversTable.find(); + expect(mocks.api.updateConfig).toHaveBeenCalledTimes(1); + expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3); + expect(locationService.getLocation().pathname).toEqual('/alerting/notifications'); + expect(mocks.api.updateConfig).toHaveBeenLastCalledWith(GRAFANA_RULES_SOURCE_NAME, { + ...someGrafanaAlertManagerConfig, + alertmanager_config: { + ...someGrafanaAlertManagerConfig.alertmanager_config, + receivers: [ + ...(someGrafanaAlertManagerConfig.alertmanager_config.receivers ?? []), + { + name: 'my new receiver', + grafana_managed_receiver_configs: [ + { + disableResolveMessage: false, + name: 'my new receiver', + secureSettings: {}, + sendReminder: true, + settings: { + apiKey: 'foobarbaz', + roomid: '', + url: 'http://hipchat', + }, + type: 'hipchat', + }, + ], + }, + ], + }, + }); + }); + + it('Cloud alertmanager receiver can be edited', async () => { + mocks.api.fetchConfig.mockResolvedValue(someCloudAlertManagerConfig); + mocks.api.updateConfig.mockResolvedValue(); + await renderReceivers('CloudManager'); + + // click edit button for the receiver + const receiversTable = await ui.receiversTable.find(); + const receiverRows = receiversTable.querySelectorAll('tbody tr'); + expect(receiverRows[0]).toHaveTextContent('cloud-receiver'); + await userEvent.click(byTestId('edit').get(receiverRows[0])); + + // check that form is open + await byRole('heading', { name: /update contact point/i }).find(); + expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/cloud-receiver/edit'); + expect(ui.channelFormContainer.queryAll()).toHaveLength(2); + + // delete the email channel + expect(ui.channelFormContainer.queryAll()).toHaveLength(2); + await userEvent.click(byTestId('items.0.delete-button').get()); + expect(ui.channelFormContainer.queryAll()).toHaveLength(1); + + // modify webhook url + const slackContainer = ui.channelFormContainer.get(); + await userEvent.click(byText('Optional Slack settings').get(slackContainer)); + userEvent.type(ui.inputs.slack.webhookURL.get(slackContainer), 'http://newgreaturl'); + + // add confirm button to action + await userEvent.click(byText(/Actions \(1\)/i).get(slackContainer)); + await userEvent.click(await byTestId('items.1.settings.actions.0.confirm.add-button').find()); + const confirmSubform = byTestId('items.1.settings.actions.0.confirm.container').get(); + await userEvent.type(byLabelText('Text').get(confirmSubform), 'confirm this'); + + // delete a field + await userEvent.click(byText(/Fields \(2\)/i).get(slackContainer)); + await userEvent.click(byTestId('items.1.settings.fields.0.delete-button').get()); + await byText(/Fields \(1\)/i).get(slackContainer); + + // add another channel + await userEvent.click(ui.newContactPointTypeButton.get()); + await clickSelectOption(await byTestId('items.2.type').find(), 'Webhook'); + await userEvent.type(await ui.inputs.webhook.URL.find(), 'http://webhookurl'); + + // it seems react-hook-form does some async state updates after submit + await act(async () => { + await userEvent.click(ui.saveContactButton.get()); + }); + + // see that we're back to main page and proper api calls have been made + await ui.receiversTable.find(); + expect(mocks.api.updateConfig).toHaveBeenCalledTimes(1); + expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3); + expect(locationService.getLocation().pathname).toEqual('/alerting/notifications'); + expect(mocks.api.updateConfig).toHaveBeenLastCalledWith('CloudManager', { + ...someCloudAlertManagerConfig, + alertmanager_config: { + ...someCloudAlertManagerConfig.alertmanager_config, + receivers: [ + { + name: 'cloud-receiver', + slack_configs: [ + { + actions: [ + { + confirm: { + text: 'confirm this', + }, + text: 'action1text', + type: 'action1type', + url: 'http://action1', + }, + ], + api_url: 'http://slack1http://newgreaturl', + channel: '#mychannel', + fields: [ + { + short: false, + title: 'field2', + value: 'text2', + }, + ], + link_names: false, + send_resolved: false, + short_fields: false, + }, + ], + webhook_configs: [ + { + send_resolved: true, + url: 'http://webhookurl', + }, + ], + }, + ], + }, + }); + }, 10000); +}); diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx new file mode 100644 index 0000000..26fcc63 --- /dev/null +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -0,0 +1,103 @@ +import { Alert, LoadingPlaceholder } from '@grafana/ui'; +import React, { FC, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { EditReceiverView } from './components/receivers/EditReceiverView'; +import { EditTemplateView } from './components/receivers/EditTemplateView'; +import { NewReceiverView } from './components/receivers/NewReceiverView'; +import { NewTemplateView } from './components/receivers/NewTemplateView'; +import { ReceiversAndTemplatesView } from './components/receivers/ReceiversAndTemplatesView'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchAlertManagerConfigAction, fetchGrafanaNotifiersAction } from './state/actions'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { initialAsyncRequestState } from './utils/redux'; + +const Receivers: FC = () => { + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const dispatch = useDispatch(); + + const location = useLocation(); + const isRoot = location.pathname.endsWith('/alerting/notifications'); + + const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs); + + const { result: config, loading, error } = + (alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState; + const receiverTypes = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); + + const shouldLoadConfig = isRoot || !config; + + useEffect(() => { + if (alertManagerSourceName && shouldLoadConfig) { + dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); + } + }, [alertManagerSourceName, dispatch, shouldLoadConfig]); + + useEffect(() => { + if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && !(receiverTypes.result || receiverTypes.loading)) { + dispatch(fetchGrafanaNotifiersAction()); + } + }, [alertManagerSourceName, dispatch, receiverTypes]); + + const disableAmSelect = !isRoot; + + if (!alertManagerSourceName) { + return ; + } + + return ( + + + {error && !loading && ( + + {error.message || 'Unknown error.'} + + )} + {loading && !config && } + {config && !error && ( + + + + + + + + + {({ match }: RouteChildrenProps<{ name: string }>) => + match?.params.name && ( + + ) + } + + + + + + {({ match }: RouteChildrenProps<{ name: string }>) => + match?.params.name && ( + + ) + } + + + )} + + ); +}; + +export default Receivers; diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx new file mode 100644 index 0000000..8db86fc --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -0,0 +1,69 @@ +import { Alert, Button, LoadingPlaceholder } from '@grafana/ui'; +import Page from 'app/core/components/Page/Page'; +import { useCleanup } from 'app/core/hooks/useCleanup'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { RuleIdentifier } from 'app/types/unified-alerting'; +import React, { FC, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchExistingRuleAction } from './state/actions'; +import { parseRuleIdentifier } from './utils/rules'; + +interface ExistingRuleEditorProps { + identifier: RuleIdentifier; +} + +const ExistingRuleEditor: FC = ({ identifier }) => { + useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule); + const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); + const dispatch = useDispatch(); + useEffect(() => { + if (!dispatched) { + dispatch(fetchExistingRuleAction(identifier)); + } + }, [dispatched, dispatch, identifier]); + + if (loading) { + return ( + + + + ); + } + if (error) { + return ( + + + {error.message} + + + ); + } + if (!result) { + return ( + + +

Sorry! This rule does not exist.

+ + + +
+
+ ); + } + return ; +}; + +type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>; + +const RuleEditor: FC = ({ match }) => { + const id = match.params.id; + if (id) { + const identifier = parseRuleIdentifier(decodeURIComponent(id)); + return ; + } + return ; +}; + +export default RuleEditor; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx new file mode 100644 index 0000000..5c4f50f --- /dev/null +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { RuleList } from './RuleList'; +import { byTestId, byText } from 'testing-library-selector'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { getAllDataSources } from './utils/config'; +import { fetchRules } from './api/prometheus'; +import { + mockDataSource, + mockPromAlert, + mockPromAlertingRule, + mockPromRecordingRule, + mockPromRuleGroup, + mockPromRuleNamespace, + MockDataSourceSrv, +} from './mocks'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { SerializedError } from '@reduxjs/toolkit'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import userEvent from '@testing-library/user-event'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { Router } from 'react-router-dom'; + +jest.mock('./api/prometheus'); +jest.mock('./utils/config'); + +const mocks = { + getAllDataSourcesMock: typeAsJestMock(getAllDataSources), + + api: { + fetchRules: typeAsJestMock(fetchRules), + }, +}; + +const renderRuleList = () => { + const store = configureStore(); + + return render( + + + + + + ); +}; + +const dataSources = { + prom: mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + }), + loki: mockDataSource({ + name: 'Loki', + type: DataSourceType.Loki, + }), + promBroken: mockDataSource({ + name: 'Prometheus-broken', + type: DataSourceType.Prometheus, + }), +}; + +const ui = { + ruleGroup: byTestId('rule-group'), + cloudRulesSourceErrors: byTestId('cloud-rulessource-errors'), + groupCollapseToggle: byTestId('group-collapse-toggle'), + ruleCollapseToggle: byTestId('rule-collapse-toggle'), + alertCollapseToggle: byTestId('alert-collapse-toggle'), + rulesTable: byTestId('rules-table'), +}; + +describe('RuleList', () => { + afterEach(() => { + jest.resetAllMocks(); + setDataSourceSrv(undefined as any); + }); + + it('load & show rule groups from multiple cloud data sources', async () => { + mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { + if (dataSourceName === dataSources.prom.name) { + return Promise.resolve([ + mockPromRuleNamespace({ + name: 'default', + dataSourceName: dataSources.prom.name, + groups: [ + mockPromRuleGroup({ + name: 'group-2', + }), + mockPromRuleGroup({ + name: 'group-1', + }), + ], + }), + ]); + } else if (dataSourceName === dataSources.loki.name) { + return Promise.resolve([ + mockPromRuleNamespace({ + name: 'default', + dataSourceName: dataSources.loki.name, + groups: [ + mockPromRuleGroup({ + name: 'group-1', + }), + ], + }), + mockPromRuleNamespace({ + name: 'lokins', + dataSourceName: dataSources.loki.name, + groups: [ + mockPromRuleGroup({ + name: 'group-1', + }), + ], + }), + ]); + } else if (dataSourceName === dataSources.promBroken.name) { + return Promise.reject({ message: 'this datasource is broken' } as SerializedError); + } else if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { + return Promise.resolve([ + mockPromRuleNamespace({ + name: 'foofolder', + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + groups: [ + mockPromRuleGroup({ + name: 'grafana-group', + rules: [ + mockPromAlertingRule({ + query: '[]', + }), + ], + }), + ], + }), + ]); + } + return Promise.reject(new Error(`unexpected datasourceName: ${dataSourceName}`)); + }); + + await renderRuleList(); + + await waitFor(() => expect(mocks.api.fetchRules).toHaveBeenCalledTimes(4)); + const groups = await ui.ruleGroup.findAll(); + expect(groups).toHaveLength(5); + + expect(groups[0]).toHaveTextContent('foofolder'); + expect(groups[1]).toHaveTextContent('default > group-1'); + expect(groups[2]).toHaveTextContent('default > group-1'); + expect(groups[3]).toHaveTextContent('default > group-2'); + expect(groups[4]).toHaveTextContent('lokins > group-1'); + + const errors = await ui.cloudRulesSourceErrors.find(); + + expect(errors).toHaveTextContent('Failed to load rules state from Prometheus-broken: this datasource is broken'); + }); + + it('expand rule group, rule and alert details', async () => { + mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); + setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { + if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { + return Promise.resolve([]); + } else { + return Promise.resolve([ + mockPromRuleNamespace({ + groups: [ + mockPromRuleGroup({ + name: 'group-1', + }), + mockPromRuleGroup({ + name: 'group-2', + rules: [ + mockPromRecordingRule({ + name: 'recordingrule', + }), + mockPromAlertingRule({ + name: 'alertingrule', + labels: { + severity: 'warning', + foo: 'bar', + }, + query: 'topk(5, foo)[5m]', + annotations: { + message: 'great alert', + }, + alerts: [ + mockPromAlert({ + labels: { + foo: 'bar', + severity: 'warning', + }, + value: '2e+10', + annotations: { + message: 'first alert message', + }, + }), + mockPromAlert({ + labels: { + foo: 'baz', + severity: 'error', + }, + value: '3e+11', + annotations: { + message: 'first alert message', + }, + }), + ], + }), + mockPromAlertingRule({ + name: 'p-rule', + alerts: [], + state: PromAlertingRuleState.Pending, + }), + mockPromAlertingRule({ + name: 'i-rule', + alerts: [], + state: PromAlertingRuleState.Inactive, + }), + ], + }), + ], + }), + ]); + } + }); + + await renderRuleList(); + + const groups = await ui.ruleGroup.findAll(); + expect(groups).toHaveLength(2); + expect(groups[0]).toHaveTextContent('1 rule'); + expect(groups[1]).toHaveTextContent('4 rules: 1 firing, 1 pending'); + + // expand second group to see rules table + expect(ui.rulesTable.query()).not.toBeInTheDocument(); + userEvent.click(ui.groupCollapseToggle.get(groups[1])); + const table = await ui.rulesTable.find(groups[1]); + + // check that rule rows are rendered properly + let ruleRows = table.querySelectorAll(':scope > tbody > tr'); + expect(ruleRows).toHaveLength(4); + + expect(ruleRows[0]).toHaveTextContent('n/a'); + expect(ruleRows[0]).toHaveTextContent('recordingrule'); + + expect(ruleRows[1]).toHaveTextContent('firing'); + expect(ruleRows[1]).toHaveTextContent('alertingrule'); + + expect(ruleRows[2]).toHaveTextContent('pending'); + expect(ruleRows[2]).toHaveTextContent('p-rule'); + + expect(ruleRows[3]).toHaveTextContent('inactive'); + expect(ruleRows[3]).toHaveTextContent('i-rule'); + + expect(byText('Labels').query()).not.toBeInTheDocument(); + + // expand alert details + userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1])); + + ruleRows = table.querySelectorAll(':scope > tbody > tr'); + expect(ruleRows).toHaveLength(5); + + const ruleDetails = ruleRows[2]; + + expect(ruleDetails).toHaveTextContent('Labelsseverity=warningfoo=bar'); + expect(ruleDetails).toHaveTextContent('Expressiontopk ( 5 , foo ) [ 5m ]'); + expect(ruleDetails).toHaveTextContent('messagegreat alert'); + expect(ruleDetails).toHaveTextContent('Matching instances'); + + // finally, check instances table + const instancesTable = ruleDetails.querySelector('table'); + expect(instancesTable).toBeInTheDocument(); + let instanceRows = instancesTable?.querySelectorAll(':scope > tbody > tr'); + expect(instanceRows).toHaveLength(2); + + expect(instanceRows![0]).toHaveTextContent('firingfoo=barseverity=warning2021-03-18 13:47:05'); + expect(instanceRows![1]).toHaveTextContent('firingfoo=bazseverity=error2021-03-18 13:47:05'); + + // expand details of an instance + userEvent.click(ui.alertCollapseToggle.get(instanceRows![0])); + instanceRows = instancesTable?.querySelectorAll(':scope > tbody > tr')!; + expect(instanceRows).toHaveLength(3); + + const alertDetails = instanceRows[1]; + expect(alertDetails).toHaveTextContent('Value2e+10'); + expect(alertDetails).toHaveTextContent('messagefirst alert message'); + + // collapse everything again + userEvent.click(ui.alertCollapseToggle.get(instanceRows![0])); + expect(instancesTable?.querySelectorAll(':scope > tbody > tr')).toHaveLength(2); + userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1])); + expect(table.querySelectorAll(':scope > tbody > tr')).toHaveLength(4); + userEvent.click(ui.groupCollapseToggle.get(groups[1])); + expect(ui.rulesTable.query()).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx new file mode 100644 index 0000000..e7a0387 --- /dev/null +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -0,0 +1,157 @@ +import { DataSourceInstanceSettings, GrafanaTheme, urlUtil } from '@grafana/data'; +import { useStyles, Button, ButtonGroup, ToolbarButton, Alert } from '@grafana/ui'; +import { SerializedError } from '@reduxjs/toolkit'; +import React, { FC, useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { NoRulesSplash } from './components/rules/NoRulesCTA'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { useFilteredRules } from './hooks/useFilteredRules'; +import { fetchAllPromAndRulerRulesAction } from './state/actions'; +import { getAllRulesSourceNames, getRulesDataSources, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { css } from '@emotion/css'; +import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; +import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants'; +import { isRulerNotSupportedResponse } from './utils/rules'; +import RulesFilter from './components/rules/RulesFilter'; +import { RuleListGroupView } from './components/rules/RuleListGroupView'; +import { RuleListStateView } from './components/rules/RuleListStateView'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +const VIEWS = { + groups: RuleListGroupView, + state: RuleListStateView, +}; + +export const RuleList: FC = () => { + const dispatch = useDispatch(); + const styles = useStyles(getStyles); + const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); + + const [queryParams] = useQueryParams(); + + const view = VIEWS[queryParams['view'] as keyof typeof VIEWS] + ? (queryParams['view'] as keyof typeof VIEWS) + : 'groups'; + + const ViewComponent = VIEWS[view]; + + // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS + useEffect(() => { + dispatch(fetchAllPromAndRulerRulesAction()); + const interval = setInterval(() => dispatch(fetchAllPromAndRulerRulesAction()), RULE_LIST_POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + }, [dispatch]); + + const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + + const dispatched = rulesDataSourceNames.some( + (name) => promRuleRequests[name]?.dispatched || rulerRuleRequests[name]?.dispatched + ); + const loading = rulesDataSourceNames.some( + (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading + ); + const haveResults = rulesDataSourceNames.some( + (name) => + (promRuleRequests[name]?.result?.length && !promRuleRequests[name]?.error) || + (Object.keys(rulerRuleRequests[name]?.result || {}).length && !rulerRuleRequests[name]?.error) + ); + + const [promReqeustErrors, rulerRequestErrors] = useMemo( + () => + [promRuleRequests, rulerRuleRequests].map((requests) => + getRulesDataSources().reduce>( + (result, dataSource) => { + const error = requests[dataSource.name]?.error; + if (requests[dataSource.name] && error && !isRulerNotSupportedResponse(requests[dataSource.name])) { + return [...result, { dataSource, error }]; + } + return result; + }, + [] + ) + ), + [promRuleRequests, rulerRuleRequests] + ); + + const grafanaPromError = promRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; + const grafanaRulerError = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; + + const showNewAlertSplash = dispatched && !loading && !haveResults; + + const combinedNamespaces = useCombinedRuleNamespaces(); + const filteredNamespaces = useFilteredRules(combinedNamespaces); + return ( + + {(promReqeustErrors.length || rulerRequestErrors.length || grafanaPromError) && ( + + {grafanaPromError && ( +
Failed to load Grafana threshold rules state: {grafanaPromError.message || 'Unknown error.'}
+ )} + {grafanaRulerError && ( +
Failed to load Grafana threshold rules config: {grafanaRulerError.message || 'Unknown error.'}
+ )} + {promReqeustErrors.map(({ dataSource, error }) => ( +
+ Failed to load rules state from {dataSource.name}:{' '} + {error.message || 'Unknown error.'} +
+ ))} + {rulerRequestErrors.map(({ dataSource, error }) => ( +
+ Failed to load rules config from {dataSource.name}:{' '} + {error.message || 'Unknown error.'} +
+ ))} +
+ )} + {!showNewAlertSplash && ( + <> + +
+
+ + + + Groups + + + + + State + + + + + + )} + {showNewAlertSplash && } + {haveResults && } + + ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + break: css` + width: 100%; + height: 0; + margin-bottom: ${theme.spacing.md}; + border-bottom: solid 1px ${theme.colors.border2}; + `, + iconError: css` + color: ${theme.palette.red}; + margin-right: ${theme.spacing.md}; + `, + buttonsContainer: css` + margin-bottom: ${theme.spacing.md}; + display: flex; + justify-content: space-between; + `, +}); diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx new file mode 100644 index 0000000..f0c0449 --- /dev/null +++ b/public/app/features/alerting/unified/Silences.tsx @@ -0,0 +1,96 @@ +import React, { FC, useEffect, useCallback } from 'react'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; + +import { useDispatch } from 'react-redux'; +import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import SilencesTable from './components/silences/SilencesTable'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions'; +import { SILENCES_POLL_INTERVAL_MS } from './utils/constants'; +import { AsyncRequestState, initialAsyncRequestState } from './utils/redux'; +import SilencesEditor from './components/silences/SilencesEditor'; +import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { Silence } from 'app/plugins/datasource/alertmanager/types'; + +const Silences: FC = () => { + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const dispatch = useDispatch(); + const silences = useUnifiedAlertingSelector((state) => state.silences); + const alertsRequests = useUnifiedAlertingSelector((state) => state.amAlerts); + const alertsRequest = alertManagerSourceName + ? alertsRequests[alertManagerSourceName] || initialAsyncRequestState + : undefined; + + const location = useLocation(); + const isRoot = location.pathname.endsWith('/alerting/silences'); + + useEffect(() => { + function fetchAll() { + if (alertManagerSourceName) { + dispatch(fetchSilencesAction(alertManagerSourceName)); + dispatch(fetchAmAlertsAction(alertManagerSourceName)); + } + } + fetchAll(); + const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + }, [alertManagerSourceName, dispatch]); + + const { result, loading, error }: AsyncRequestState = + (alertManagerSourceName && silences[alertManagerSourceName]) || initialAsyncRequestState; + + const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]); + + if (!alertManagerSourceName) { + return ; + } + + return ( + + + {error && !loading && ( + + {error.message || 'Unknown error.'} + + )} + {alertsRequest?.error && !alertsRequest?.loading && ( + + {alertsRequest.error?.message || 'Unknown error.'} + + )} + {loading && } + {result && !error && ( + + + + + + + + + {({ match }: RouteChildrenProps<{ id: string }>) => { + return ( + match?.params.id && ( + + ) + ); + }} + + + )} + + ); +}; + +export default Silences; diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts new file mode 100644 index 0000000..35be36a --- /dev/null +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -0,0 +1,132 @@ +import { urlUtil } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { + AlertmanagerAlert, + AlertManagerCortexConfig, + AlertmanagerGroup, + Silence, + SilenceCreatePayload, + SilenceMatcher, +} from 'app/plugins/datasource/alertmanager/types'; +import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +// "grafana" for grafana-managed, otherwise a datasource name +export async function fetchAlertManagerConfig(alertManagerSourceName: string): Promise { + try { + const result = await getBackendSrv() + .fetch({ + url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/alerts`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + return { + template_files: result.data.template_files ?? {}, + alertmanager_config: result.data.alertmanager_config ?? {}, + }; + } catch (e) { + // if no config has been uploaded to grafana, it returns error instead of latest config + if ( + alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && + (e.data?.message?.includes('failed to get latest configuration') || + e.data?.message?.includes('could not find an Alertmanager configuration')) + ) { + return { + template_files: {}, + alertmanager_config: {}, + }; + } + throw e; + } +} + +export async function updateAlertManagerConfig( + alertManagerSourceName: string, + config: AlertManagerCortexConfig +): Promise { + await getBackendSrv() + .fetch({ + method: 'POST', + url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/alerts`, + data: config, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); +} + +export async function fetchSilences(alertManagerSourceName: string): Promise { + const result = await getBackendSrv() + .fetch({ + url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/api/v2/silences`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + return result.data; +} + +// returns the new silence ID. Even in the case of an update, a new silence is created and the previous one expired. +export async function createOrUpdateSilence( + alertmanagerSourceName: string, + payload: SilenceCreatePayload +): Promise { + const result = await getBackendSrv().post( + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`, + payload + ); + return result.data; +} + +export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise { + await getBackendSrv().delete( + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silence/${encodeURIComponent(silenceID)}` + ); +} + +export async function fetchAlerts( + alertmanagerSourceName: string, + matchers?: SilenceMatcher[], + silenced = true, + active = true, + inhibited = true +): Promise { + const filters = + urlUtil.toUrlParams({ silenced, active, inhibited }) + + matchers + ?.map( + (matcher) => + `filter=${encodeURIComponent( + `${escapeQuotes(matcher.name)}=${matcher.isRegex ? '~' : ''}"${escapeQuotes(matcher.value)}"` + )}` + ) + .join('&') || ''; + + const result = await getBackendSrv() + .fetch({ + url: + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/alerts` + + (filters ? '?' + filters : ''), + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + return result.data; +} + +export async function fetchAlertGroups(alertmanagerSourceName: string): Promise { + const result = await getBackendSrv() + .fetch({ + url: `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/alerts/groups`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + return result.data; +} + +function escapeQuotes(value: string): string { + return value.replace(/"/g, '\\"'); +} diff --git a/public/app/features/alerting/unified/api/grafana.ts b/public/app/features/alerting/unified/api/grafana.ts new file mode 100644 index 0000000..dcca81c --- /dev/null +++ b/public/app/features/alerting/unified/api/grafana.ts @@ -0,0 +1,6 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { NotifierDTO } from 'app/types'; + +export function fetchNotifiers(): Promise { + return getBackendSrv().get(`/api/alert-notifiers`); +} diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts new file mode 100644 index 0000000..d8379dc --- /dev/null +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -0,0 +1,32 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { RuleNamespace } from 'app/types/unified-alerting'; +import { PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { getDatasourceAPIId } from '../utils/datasource'; + +export async function fetchRules(dataSourceName: string): Promise { + const response = await getBackendSrv() + .fetch({ + url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + const nsMap: { [key: string]: RuleNamespace } = {}; + response.data.data.groups.forEach((group) => { + group.rules.forEach((rule) => { + rule.query = rule.query || ''; // @TODO temp fix, backend response ism issing query. remove once it's there + }); + if (!nsMap[group.file]) { + nsMap[group.file] = { + dataSourceName, + name: group.file, + groups: [group], + }; + } else { + nsMap[group.file].groups.push(group); + } + }); + + return Object.values(nsMap); +} diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts new file mode 100644 index 0000000..c3a515d --- /dev/null +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -0,0 +1,86 @@ +import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { getDatasourceAPIId } from '../utils/datasource'; +import { getBackendSrv } from '@grafana/runtime'; +import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; + +// upsert a rule group. use this to update rules +export async function setRulerRuleGroup( + dataSourceName: string, + namespace: string, + group: PostableRulerRuleGroupDTO +): Promise { + await await getBackendSrv() + .fetch({ + method: 'POST', + url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + data: group, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); +} + +// fetch all ruler rule namespaces and included groups +export async function fetchRulerRules(dataSourceName: string) { + return rulerGetRequest(`/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, {}); +} + +// fetch rule groups for a particular namespace +// will throw with { status: 404 } if namespace does not exist +export async function fetchRulerRulesNamespace(dataSourceName: string, namespace: string) { + const result = await rulerGetRequest>( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + {} + ); + return result[namespace] || []; +} + +// fetch a particular rule group +// will throw with { status: 404 } if rule group does not exist +export async function fetchRulerRulesGroup( + dataSourceName: string, + namespace: string, + group: string +): Promise { + return rulerGetRequest( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( + namespace + )}/${encodeURIComponent(group)}`, + null + ); +} + +export async function deleteRulerRulesGroup(dataSourceName: string, namespace: string, groupName: string) { + return getBackendSrv().delete( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( + namespace + )}/${encodeURIComponent(groupName)}` + ); +} + +// false in case ruler is not supported. this is weird, but we'll work on it +async function rulerGetRequest(url: string, empty: T): Promise { + try { + const response = await getBackendSrv() + .fetch({ + url, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + return response.data; + } catch (e) { + if (e?.status === 404 || e?.data?.message?.includes('group does not exist')) { + return empty; + } else if (e?.status === 500 && e?.data?.message?.includes('mapping values are not allowed in this context')) { + throw { + ...e, + data: { + ...e?.data, + message: RULER_NOT_SUPPORTED_MSG, + }, + }; + } + throw e; + } +} diff --git a/public/app/features/alerting/unified/components/AlertLabel.tsx b/public/app/features/alerting/unified/components/AlertLabel.tsx new file mode 100644 index 0000000..6594a8c --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertLabel.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; +import { IconButton, useStyles } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +interface Props { + labelKey: string; + value: string; + isRegex?: boolean; + onRemoveLabel?: () => void; +} + +export const AlertLabel: FC = ({ labelKey, value, isRegex = false, onRemoveLabel }) => ( +
+ {labelKey}={isRegex && '~'} + {value} + {!!onRemoveLabel && } +
+); + +export const getStyles = (theme: GrafanaTheme) => css` + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + border: solid 1px ${theme.colors.border2}; + font-size: ${theme.typography.size.sm}; + background-color: ${theme.colors.bg2}; + font-weight: ${theme.typography.weight.bold}; + color: ${theme.colors.formLabel}; + display: inline-block; + line-height: 1.2; +`; diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx new file mode 100644 index 0000000..9c886ec --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -0,0 +1,30 @@ +import { GrafanaTheme } from '@grafana/data'; +import { useStyles } from '@grafana/ui'; +import { css } from '@emotion/css'; +import React from 'react'; +import { AlertLabel } from './AlertLabel'; + +type Props = { labels: Record }; + +export const AlertLabels = ({ labels }: Props) => { + const styles = useStyles(getStyles); + const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__'))); + + return ( +
+ {pairs.map(([key, value], index) => ( + + ))} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + & > * { + margin-top: ${theme.spacing.xs}; + margin-right: ${theme.spacing.xs}; + } + padding-bottom: ${theme.spacing.xs}; + `, +}); diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx new file mode 100644 index 0000000..4ba5083 --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -0,0 +1,67 @@ +import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import React, { FC, useMemo } from 'react'; +import { Field, Select, useStyles2 } from '@grafana/ui'; +import { getAllDataSources } from '../utils/config'; +import { css } from '@emotion/css'; + +interface Props { + onChange: (alertManagerSourceName: string) => void; + current?: string; + disabled?: boolean; +} + +export const AlertManagerPicker: FC = ({ onChange, current, disabled = false }) => { + const styles = useStyles2(getStyles); + + const options: Array> = useMemo(() => { + return [ + { + label: 'Grafana', + value: GRAFANA_RULES_SOURCE_NAME, + imgUrl: 'public/img/grafana_icon.svg', + meta: {}, + }, + ...getAllDataSources() + .filter((ds) => ds.type === DataSourceType.Alertmanager) + .map((ds) => ({ + label: ds.name.substr(0, 37), + value: ds.name, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + })), + ]; + }, []); + + // no need to show the picker if there's only one option + if (options.length === 1) { + return null; + } + + return ( + + onChange(mapSelectValueToString(value))} + options={receivers} + /> + )} + control={control} + name="receiver" + /> + or + + Create a contact point + +
+ + + {/* @ts-ignore-check: react-hook-form made me do this */} + ( + { + setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]); + + // @ts-ignore-check: react-hook-form made me do this + setValue('groupBy', [...field.value, opt]); + }} + onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} + options={groupByOptions} + /> + )} + control={control} + name="groupBy" + /> + + + + <> +
+ ( + + )} + control={control} + name="groupWaitValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="groupIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="repeatIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + + = + + + + + + + + ); + })} +
+ + + )} + + + {/* @ts-ignore-check: react-hook-form made me do this */} + ( + + )} + control={control} + name="groupWaitValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="groupIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="repeatIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + + + You can use the{' '} + + Go templating language + + .{' '} + + More info about alertmanager templates + + + } + label="Content" + error={errors?.content?.message} + invalid={!!errors.content?.message} + > + +
+ +
+ Tags + + +
+ +
+ + + Cancel +
+
+ +
diff --git a/public/app/features/annotations/specs/annotations_srv.test.ts b/public/app/features/annotations/specs/annotations_srv.test.ts new file mode 100644 index 0000000..5329a14 --- /dev/null +++ b/public/app/features/annotations/specs/annotations_srv.test.ts @@ -0,0 +1,30 @@ +import { AnnotationsSrv } from '../annotations_srv'; + +describe('AnnotationsSrv', () => { + const annotationsSrv = new AnnotationsSrv(); + + describe('When translating the query result', () => { + const annotationSource = { + datasource: '-- Grafana --', + enable: true, + hide: false, + limit: 200, + name: 'test', + scope: 'global', + tags: ['test'], + type: 'event', + }; + + const time = 1507039543000; + const annotations = [{ id: 1, panelId: 1, text: 'text', time: time }]; + let translatedAnnotations: any; + + beforeEach(() => { + translatedAnnotations = annotationsSrv.translateQueryResult(annotationSource, annotations); + }); + + it('should set defaults', () => { + expect(translatedAnnotations[0].source).toEqual(annotationSource); + }); + }); +}); diff --git a/public/app/features/annotations/specs/annotations_srv_specs.test.ts b/public/app/features/annotations/specs/annotations_srv_specs.test.ts new file mode 100644 index 0000000..825b992 --- /dev/null +++ b/public/app/features/annotations/specs/annotations_srv_specs.test.ts @@ -0,0 +1,39 @@ +import { dedupAnnotations } from '../events_processing'; + +describe('Annotations deduplication', () => { + it('should remove duplicated annotations', () => { + const testAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 2, time: 2 }, + { id: 5, time: 5 }, + { id: 5, time: 5 }, + ]; + const expectedAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 5, time: 5 }, + ]; + + const deduplicated = dedupAnnotations(testAnnotations); + expect(deduplicated).toEqual(expectedAnnotations); + }); + + it('should leave non "panel-alert" event if present', () => { + const testAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 2, time: 2, eventType: 'panel-alert' }, + { id: 5, time: 5 }, + { id: 5, time: 5 }, + ]; + const expectedAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 5, time: 5 }, + ]; + + const deduplicated = dedupAnnotations(testAnnotations); + expect(deduplicated).toEqual(expectedAnnotations); + }); +}); diff --git a/public/app/features/annotations/standardAnnotationSupport.test.ts b/public/app/features/annotations/standardAnnotationSupport.test.ts new file mode 100644 index 0000000..4be61ab --- /dev/null +++ b/public/app/features/annotations/standardAnnotationSupport.test.ts @@ -0,0 +1,97 @@ +import { FieldType, toDataFrame } from '@grafana/data'; +import { getAnnotationsFromData } from './standardAnnotationSupport'; + +describe('DataFrame to annotations', () => { + test('simple conversion', async () => { + const frame = toDataFrame({ + fields: [ + { type: FieldType.time, values: [1, 2, 3, 4, 5] }, + { name: 'first string field', values: ['t1', 't2', 't3', null, undefined] }, + { name: 'tags', values: ['aaa,bbb', 'bbb,ccc', 'zyz', null, undefined] }, + ], + }); + + await expect(getAnnotationsFromData([frame])).toEmitValues([ + [ + { + color: 'red', + tags: ['aaa', 'bbb'], + text: 't1', + time: 1, + type: 'default', + }, + { + color: 'red', + tags: ['bbb', 'ccc'], + text: 't2', + time: 2, + type: 'default', + }, + { + color: 'red', + tags: ['zyz'], + text: 't3', + time: 3, + type: 'default', + }, + { + color: 'red', + time: 4, + type: 'default', + }, + { + color: 'red', + time: 5, + type: 'default', + }, + ], + ]); + }); + + test('explicit mappins', async () => { + const frame = toDataFrame({ + fields: [ + { name: 'time1', values: [111, 222, 333] }, + { name: 'time2', values: [100, 200, 300] }, + { name: 'aaaaa', values: ['a1', 'a2', 'a3'] }, + { name: 'bbbbb', values: ['b1', 'b2', 'b3'] }, + ], + }); + + const observable = getAnnotationsFromData([frame], { + text: { value: 'bbbbb' }, + time: { value: 'time2' }, + timeEnd: { value: 'time1' }, + title: { value: 'aaaaa' }, + }); + + await expect(observable).toEmitValues([ + [ + { + color: 'red', + text: 'b1', + time: 100, + timeEnd: 111, + title: 'a1', + type: 'default', + }, + { + color: 'red', + text: 'b2', + time: 200, + timeEnd: 222, + title: 'a2', + type: 'default', + }, + { + color: 'red', + text: 'b3', + time: 300, + timeEnd: 333, + title: 'a3', + type: 'default', + }, + ], + ]); + }); +}); diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts new file mode 100644 index 0000000..9e2653f --- /dev/null +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -0,0 +1,212 @@ +import { Observable, of, OperatorFunction } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { + AnnotationEvent, + AnnotationEventFieldSource, + AnnotationEventMappings, + AnnotationQuery, + AnnotationSupport, + DataFrame, + Field, + FieldType, + getFieldDisplayName, + KeyValue, + standardTransformers, +} from '@grafana/data'; + +import { isString } from 'lodash'; + +export const standardAnnotationSupport: AnnotationSupport = { + /** + * Assume the stored value is standard model. + */ + prepareAnnotation: (json: any) => { + if (isString(json?.query)) { + const { query, ...rest } = json; + return { + ...rest, + target: { + query, + }, + mappings: {}, + }; + } + return json as AnnotationQuery; + }, + + /** + * Convert the stored JSON model and environment to a standard data source query object. + * This query will be executed in the data source and the results converted into events. + * Returning an undefined result will quietly skip query execution + */ + prepareQuery: (anno: AnnotationQuery) => anno.target, + + /** + * When the standard frame > event processing is insufficient, this allows explicit control of the mappings + */ + processEvents: (anno: AnnotationQuery, data: DataFrame[]) => { + return getAnnotationsFromData(data, anno.mappings); + }, +}; + +/** + * Flatten all panel data into a single frame + */ + +export function singleFrameFromPanelData(): OperatorFunction { + return (source) => + source.pipe( + mergeMap((data) => { + if (!data?.length) { + return of(undefined); + } + + if (data.length === 1) { + return of(data[0]); + } + + return of(data).pipe( + standardTransformers.mergeTransformer.operator({}), + map((d) => d[0]) + ); + }) + ); +} + +interface AnnotationEventFieldSetter { + key: keyof AnnotationEvent; + field?: Field; + text?: string; + regex?: RegExp; + split?: string; // for tags +} + +export interface AnnotationFieldInfo { + key: keyof AnnotationEvent; + + split?: string; + field?: (frame: DataFrame) => Field | undefined; + placeholder?: string; + help?: string; +} + +export const annotationEventNames: AnnotationFieldInfo[] = [ + { + key: 'time', + field: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.time), + placeholder: 'time, or the first time field', + }, + { key: 'timeEnd', help: 'When this field is defined, the annotation will be treated as a range' }, + { + key: 'title', + }, + { + key: 'text', + field: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.string), + placeholder: 'text, or the first text field', + }, + { key: 'tags', split: ',', help: 'The results will be split on comma (,)' }, + // { key: 'userId' }, + // { key: 'login' }, + // { key: 'email' }, +]; + +export function getAnnotationsFromData( + data: DataFrame[], + options?: AnnotationEventMappings +): Observable { + return of(data).pipe( + singleFrameFromPanelData(), + map((frame) => { + if (!frame?.length) { + return []; + } + + let hasTime = false; + let hasText = false; + const byName: KeyValue = {}; + + for (const f of frame.fields) { + const name = getFieldDisplayName(f, frame); + byName[name.toLowerCase()] = f; + } + + if (!options) { + options = {}; + } + + const fields: AnnotationEventFieldSetter[] = []; + + for (const evts of annotationEventNames) { + const opt = options[evts.key] || {}; //AnnotationEventFieldMapping + + if (opt.source === AnnotationEventFieldSource.Skip) { + continue; + } + + const setter: AnnotationEventFieldSetter = { key: evts.key, split: evts.split }; + + if (opt.source === AnnotationEventFieldSource.Text) { + setter.text = opt.value; + } else { + const lower = (opt.value || evts.key).toLowerCase(); + setter.field = byName[lower]; + + if (!setter.field && evts.field) { + setter.field = evts.field(frame); + } + } + + if (setter.field || setter.text) { + fields.push(setter); + if (setter.key === 'time') { + hasTime = true; + } else if (setter.key === 'text') { + hasText = true; + } + } + } + + if (!hasTime || !hasText) { + return []; // throw an error? + } + + // Add each value to the string + const events: AnnotationEvent[] = []; + + for (let i = 0; i < frame.length; i++) { + const anno: AnnotationEvent = { + type: 'default', + color: 'red', + }; + + for (const f of fields) { + let v: any = undefined; + + if (f.text) { + v = f.text; // TODO support templates! + } else if (f.field) { + v = f.field.values.get(i); + if (v !== undefined && f.regex) { + const match = f.regex.exec(v); + if (match) { + v = match[1] ? match[1] : match[0]; + } + } + } + + if (v !== null && v !== undefined) { + if (f.split && typeof v === 'string') { + v = v.split(','); + } + (anno as any)[f.key] = v; + } + } + + events.push(anno); + } + + return events; + }) + ); +} diff --git a/public/app/features/annotations/types.ts b/public/app/features/annotations/types.ts new file mode 100644 index 0000000..eaf0e43 --- /dev/null +++ b/public/app/features/annotations/types.ts @@ -0,0 +1,20 @@ +import { AnnotationEvent, PanelData, TimeRange } from '@grafana/data'; +import { DashboardModel, PanelModel } from '../dashboard/state'; + +export interface AnnotationQueryOptions { + dashboard: DashboardModel; + panel: PanelModel; + range: TimeRange; +} + +export interface AnnotationQueryResponse { + /** + * The processed annotation events + */ + events?: AnnotationEvent[]; + + /** + * The original panel response + */ + panelData?: PanelData; +} diff --git a/public/app/features/api-keys/ApiKeysActionBar.tsx b/public/app/features/api-keys/ApiKeysActionBar.tsx new file mode 100644 index 0000000..165c4e4 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysActionBar.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { Button } from '@grafana/ui'; +import { FilterInput } from '../../core/components/FilterInput/FilterInput'; + +interface Props { + searchQuery: string; + disabled: boolean; + onAddClick: () => void; + onSearchChange: (value: string) => void; +} + +export const ApiKeysActionBar: FC = ({ searchQuery, disabled, onAddClick, onSearchChange }) => { + return ( +
+
+ +
+ +
+ ); +}; diff --git a/public/app/features/api-keys/ApiKeysAddedModal.test.tsx b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx new file mode 100644 index 0000000..82a3d7d --- /dev/null +++ b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ApiKeysAddedModal, Props } from './ApiKeysAddedModal'; + +const setup = (propOverrides?: object) => { + const props: Props = { + onDismiss: jest.fn(), + apiKey: 'api key test', + rootPath: 'test/path', + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + + return { + wrapper, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/api-keys/ApiKeysAddedModal.tsx b/public/app/features/api-keys/ApiKeysAddedModal.tsx new file mode 100644 index 0000000..8904377 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysAddedModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { Alert, Field, Modal, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; + +export interface Props { + onDismiss: () => void; + apiKey: string; + rootPath: string; +} + +export function ApiKeysAddedModal({ onDismiss, apiKey, rootPath }: Props): JSX.Element { + const styles = useStyles2(getStyles); + return ( + + + {apiKey} + + + + It is not stored in this form, so be sure to copy it now. + + +

You can authenticate a request using the Authorization HTTP header, example:

+
+        curl -H "Authorization: Bearer {apiKey}" {rootPath}/api/dashboards/home
+      
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + label: css` + padding: ${theme.spacing(1)}; + background-color: ${theme.colors.background.secondary}; + border-radius: ${theme.shape.borderRadius()}; + `, + small: css` + font-size: ${theme.typography.bodySmall.fontSize}; + font-weight: ${theme.typography.bodySmall.fontWeight}; + `, + }; +} diff --git a/public/app/features/api-keys/ApiKeysController.tsx b/public/app/features/api-keys/ApiKeysController.tsx new file mode 100644 index 0000000..69ac055 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysController.tsx @@ -0,0 +1,19 @@ +import { FC, useCallback, useState } from 'react'; + +interface Api { + isAdding: boolean; + toggleIsAdding: () => void; +} + +interface Props { + children: (props: Api) => JSX.Element; +} + +export const ApiKeysController: FC = ({ children }) => { + const [isAdding, setIsAdding] = useState(false); + const toggleIsAdding = useCallback(() => { + setIsAdding(!isAdding); + }, [isAdding]); + + return children({ isAdding, toggleIsAdding }); +}; diff --git a/public/app/features/api-keys/ApiKeysForm.tsx b/public/app/features/api-keys/ApiKeysForm.tsx new file mode 100644 index 0000000..2238aec --- /dev/null +++ b/public/app/features/api-keys/ApiKeysForm.tsx @@ -0,0 +1,110 @@ +import React, { ChangeEvent, FC, FormEvent, useEffect, useState } from 'react'; +import { EventsWithValidation, InlineFormLabel, LegacyForms, ValidationEvents, Button } from '@grafana/ui'; +import { NewApiKey, OrgRole } from '../../types'; +import { rangeUtil } from '@grafana/data'; +import { SlideDown } from '../../core/components/Animations/SlideDown'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; + +const { Input } = LegacyForms; + +interface Props { + show: boolean; + onClose: () => void; + onKeyAdded: (apiKey: NewApiKey) => void; +} + +function isValidInterval(value: string): boolean { + if (!value) { + return true; + } + try { + rangeUtil.intervalToSeconds(value); + return true; + } catch {} + return false; +} + +const timeRangeValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: isValidInterval, + errorMessage: 'Not a valid duration', + }, + ], +}; + +const tooltipText = + 'The API key life duration. For example, 1d if your key is going to last for one day. Supported units are: s,m,h,d,w,M,y'; + +export const ApiKeysForm: FC = ({ show, onClose, onKeyAdded }) => { + const [name, setName] = useState(''); + const [role, setRole] = useState(OrgRole.Viewer); + const [secondsToLive, setSecondsToLive] = useState(''); + useEffect(() => { + setName(''); + setRole(OrgRole.Viewer); + setSecondsToLive(''); + }, [show]); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + if (isValidInterval(secondsToLive)) { + onKeyAdded({ name, role, secondsToLive }); + onClose(); + } + }; + const onNameChange = (event: ChangeEvent) => { + setName(event.currentTarget.value); + }; + const onRoleChange = (event: ChangeEvent) => { + setRole(event.currentTarget.value as OrgRole); + }; + const onSecondsToLiveChange = (event: ChangeEvent) => { + setSecondsToLive(event.currentTarget.value); + }; + + return ( + +
+ +
+
Add API Key
+
+
+ Key name + +
+
+ Role + + + +
+
+ Time to live + +
+
+ +
+
+
+
+
+ ); +}; diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx new file mode 100644 index 0000000..d81cc7a --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { ApiKeysPageUnconnected, Props } from './ApiKeysPage'; +import { ApiKey, OrgRole } from 'app/types'; +import { NavModel } from '@grafana/data'; +import { setSearchQuery } from './state/reducers'; +import { mockToolkitActionCreator } from '../../../test/core/redux/mocks'; +import { getMultipleMockKeys } from './__mocks__/apiKeysMock'; +import { selectors } from '@grafana/e2e-selectors'; +import userEvent from '@testing-library/user-event'; +import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; + +const setup = (propOverrides: Partial) => { + const loadApiKeysMock = jest.fn(); + const deleteApiKeyMock = jest.fn(); + const addApiKeyMock = jest.fn(); + const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery); + const props: Props = { + navModel: { + main: { + text: 'Configuration', + }, + node: { + text: 'Api Keys', + }, + } as NavModel, + apiKeys: [] as ApiKey[], + searchQuery: '', + hasFetched: false, + loadApiKeys: loadApiKeysMock, + deleteApiKey: deleteApiKeyMock, + setSearchQuery: setSearchQueryMock, + addApiKey: addApiKeyMock, + apiKeysCount: 0, + timeZone: 'utc', + }; + + Object.assign(props, propOverrides); + + const { rerender } = render(); + return { rerender, props, loadApiKeysMock, setSearchQueryMock, deleteApiKeyMock, addApiKeyMock }; +}; + +describe('ApiKeysPage', () => { + silenceConsoleOutput(); + describe('when mounted', () => { + it('then it should call loadApiKeys without expired', () => { + const { loadApiKeysMock } = setup({}); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(false); + }); + }); + + describe('when loading', () => { + it('then should show Loading message', () => { + setup({ hasFetched: false }); + expect(screen.getByText(/loading \.\.\./i)).toBeInTheDocument(); + }); + }); + + describe('when there are no API keys', () => { + it('then it should render CTA', () => { + setup({ apiKeys: getMultipleMockKeys(0), apiKeysCount: 0, hasFetched: true }); + expect(screen.getByLabelText(selectors.components.CallToActionCard.button('New API key'))).toBeInTheDocument(); + }); + }); + + describe('when there are API keys', () => { + it('then it should render API keys table', async () => { + const apiKeys = [ + { id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' }, + { id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' }, + { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, + ]; + setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row').length).toBe(4); + expect(screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /third viewer no expiration date cancel delete/i })).toBeInTheDocument(); + }); + }); + + describe('when a user toggles the Show expired toggle', () => { + it('then it should call loadApiKeys with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(3); + const { loadApiKeysMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + loadApiKeysMock.mockClear(); + toggleShowExpired(); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(true); + + loadApiKeysMock.mockClear(); + toggleShowExpired(); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(false); + }); + }); + + describe('when a user searches for an API key', () => { + it('then it should dispatch setSearchQuery with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(3); + const { setSearchQueryMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + setSearchQueryMock.mockClear(); + expect(screen.getByPlaceholderText(/search keys/i)).toBeInTheDocument(); + await userEvent.type(screen.getByPlaceholderText(/search keys/i), 'First'); + expect(setSearchQueryMock).toHaveBeenCalledTimes(5); + }); + }); + + describe('when a user deletes an API key', () => { + it('then it should dispatch deleteApi with correct parameters', async () => { + const apiKeys = [ + { id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' }, + { id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' }, + { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, + ]; + const { deleteApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + const firstRow = screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i }); + const secondRow = screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i }); + + deleteApiKeyMock.mockClear(); + expect(within(firstRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); + userEvent.click(within(firstRow).getByRole('cell', { name: /cancel delete/i })); + expect(within(firstRow).getByRole('button', { name: /delete/i })).toBeInTheDocument(); + userEvent.click(within(firstRow).getByRole('button', { name: /delete/i })); + expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); + expect(deleteApiKeyMock).toHaveBeenCalledWith(1, false); + + toggleShowExpired(); + + deleteApiKeyMock.mockClear(); + expect(within(secondRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); + userEvent.click(within(secondRow).getByRole('cell', { name: /cancel delete/i })); + expect(within(secondRow).getByRole('button', { name: /delete/i })).toBeInTheDocument(); + userEvent.click(within(secondRow).getByRole('button', { name: /delete/i })); + expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); + expect(deleteApiKeyMock).toHaveBeenCalledWith(2, true); + }); + }); + + describe('when a user adds an API key from CTA', () => { + it('then it should call addApiKey with correct parameters', async () => { + const apiKeys: any[] = []; + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByLabelText(selectors.components.CallToActionCard.button('New API key'))); + await addAndVerifyApiKey(addApiKeyMock, false); + }); + }); + + describe('when a user adds an API key from Add API key', () => { + it('then it should call addApiKey with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(1); + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await addAndVerifyApiKey(addApiKeyMock, false); + + toggleShowExpired(); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await addAndVerifyApiKey(addApiKeyMock, true); + }); + }); + + describe('when a user adds an API key with an invalid expiration', () => { + it('then it should display a message', async () => { + const apiKeys = getMultipleMockKeys(1); + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test'); + await userEvent.type(screen.getByPlaceholderText(/1d/i), '60x'); + expect(screen.queryByText(/not a valid duration/i)).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: /^add$/i })); + expect(screen.getByText(/not a valid duration/i)).toBeInTheDocument(); + expect(addApiKeyMock).toHaveBeenCalledTimes(0); + }); + }); +}); + +function toggleShowExpired() { + expect(screen.queryByLabelText(/show expired/i)).toBeInTheDocument(); + userEvent.click(screen.getByLabelText(/show expired/i)); +} + +async function addAndVerifyApiKey(addApiKeyMock: jest.Mock, includeExpired: boolean) { + expect(screen.getByRole('heading', { name: /add api key/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/1d/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^add$/i })).toBeInTheDocument(); + + await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test'); + await userEvent.type(screen.getByPlaceholderText(/1d/i), '60s'); + userEvent.click(screen.getByRole('button', { name: /^add$/i })); + expect(addApiKeyMock).toHaveBeenCalledTimes(1); + expect(addApiKeyMock).toHaveBeenCalledWith( + { name: 'Test', role: 'Viewer', secondsToLive: 60 }, + expect.anything(), + includeExpired + ); +} diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx new file mode 100644 index 0000000..38325c3 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -0,0 +1,171 @@ +import React, { PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import { hot } from 'react-hot-loader'; +// Utils +import { ApiKey, NewApiKey, StoreState } from 'app/types'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getApiKeys, getApiKeysCount } from './state/selectors'; +import { addApiKey, deleteApiKey, loadApiKeys } from './state/actions'; +import Page from 'app/core/components/Page/Page'; +import { ApiKeysAddedModal } from './ApiKeysAddedModal'; +import config from 'app/core/config'; +import appEvents from 'app/core/app_events'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { InlineField, InlineSwitch, VerticalGroup } from '@grafana/ui'; +import { rangeUtil } from '@grafana/data'; +import { getTimeZone } from 'app/features/profile/state/selectors'; +import { setSearchQuery } from './state/reducers'; +import { ApiKeysForm } from './ApiKeysForm'; +import { ApiKeysActionBar } from './ApiKeysActionBar'; +import { ApiKeysTable } from './ApiKeysTable'; +import { ApiKeysController } from './ApiKeysController'; +import { ShowModalReactEvent } from 'app/types/events'; + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'apikeys'), + apiKeys: getApiKeys(state.apiKeys), + searchQuery: state.apiKeys.searchQuery, + apiKeysCount: getApiKeysCount(state.apiKeys), + hasFetched: state.apiKeys.hasFetched, + timeZone: getTimeZone(state.user), + }; +} + +const mapDispatchToProps = { + loadApiKeys, + deleteApiKey, + setSearchQuery, + addApiKey, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +interface OwnProps {} + +export type Props = OwnProps & ConnectedProps; + +interface State { + includeExpired: boolean; + hasFetched: boolean; +} + +export class ApiKeysPageUnconnected extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { includeExpired: false, hasFetched: false }; + } + + componentDidMount() { + this.fetchApiKeys(); + } + + async fetchApiKeys() { + await this.props.loadApiKeys(this.state.includeExpired); + } + + onDeleteApiKey = (key: ApiKey) => { + this.props.deleteApiKey(key.id!, this.state.includeExpired); + }; + + onSearchQueryChange = (value: string) => { + this.props.setSearchQuery(value); + }; + + onIncludeExpiredChange = (event: React.SyntheticEvent) => { + this.setState({ hasFetched: false, includeExpired: event.currentTarget.checked }, this.fetchApiKeys); + }; + + onAddApiKey = (newApiKey: NewApiKey) => { + const openModal = (apiKey: string) => { + const rootPath = window.location.origin + config.appSubUrl; + + appEvents.publish( + new ShowModalReactEvent({ + props: { + apiKey, + rootPath, + }, + component: ApiKeysAddedModal, + }) + ); + }; + + const secondsToLive = newApiKey.secondsToLive; + try { + const secondsToLiveAsNumber = secondsToLive ? rangeUtil.intervalToSeconds(secondsToLive) : null; + const apiKey: ApiKey = { + ...newApiKey, + secondsToLive: secondsToLiveAsNumber, + }; + this.props.addApiKey(apiKey, openModal, this.state.includeExpired); + this.setState((prevState: State) => { + return { + ...prevState, + isAdding: false, + }; + }); + } catch (err) { + console.error(err); + } + }; + + render() { + const { hasFetched, navModel, apiKeysCount, apiKeys, searchQuery, timeZone } = this.props; + const { includeExpired } = this.state; + + if (!hasFetched) { + return ( + + {} + + ); + } + + return ( + + + + {({ isAdding, toggleIsAdding }) => { + const showCTA = !isAdding && apiKeysCount === 0; + const showTable = apiKeysCount > 0; + return ( + <> + {showCTA ? ( + + ) : null} + {showTable ? ( + + ) : null} + + {showTable ? ( + + + + + + + ) : null} + + ); + }} + + + + ); + } +} + +const ApiKeysPage = connector(ApiKeysPageUnconnected); +export default hot(module)(ApiKeysPage); diff --git a/public/app/features/api-keys/ApiKeysTable.tsx b/public/app/features/api-keys/ApiKeysTable.tsx new file mode 100644 index 0000000..5620385 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysTable.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { DeleteButton } from '@grafana/ui'; +import { dateTimeFormat, TimeZone } from '@grafana/data'; + +import { ApiKey } from '../../types'; + +interface Props { + apiKeys: ApiKey[]; + timeZone: TimeZone; + onDelete: (apiKey: ApiKey) => void; +} + +export const ApiKeysTable: FC = ({ apiKeys, timeZone, onDelete }) => { + return ( + + + + + + + + + {apiKeys.length > 0 ? ( + + {apiKeys.map((key) => { + return ( + + + + + + + ); + })} + + ) : null} +
NameRoleExpires +
{key.name}{key.role}{formatDate(key.expiration, timeZone)} + onDelete(key)} /> +
+ ); +}; + +function formatDate(expiration: string | undefined, timeZone: TimeZone): string { + if (!expiration) { + return 'No expiration date'; + } + return dateTimeFormat(expiration, { timeZone }); +} diff --git a/public/app/features/api-keys/__mocks__/apiKeysMock.ts b/public/app/features/api-keys/__mocks__/apiKeysMock.ts new file mode 100644 index 0000000..c244cb6 --- /dev/null +++ b/public/app/features/api-keys/__mocks__/apiKeysMock.ts @@ -0,0 +1,27 @@ +import { ApiKey, OrgRole } from 'app/types'; + +export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => { + const keys: ApiKey[] = []; + + for (let i = 1; i <= numberOfKeys; i++) { + keys.push({ + id: i, + name: `test-${i}`, + role: OrgRole.Viewer, + secondsToLive: 100, + expiration: '2019-06-04', + }); + } + + return keys; +}; + +export const getMockKey = (): ApiKey => { + return { + id: 1, + name: 'test', + role: OrgRole.Admin, + secondsToLive: 200, + expiration: '2019-06-04', + }; +}; diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap new file mode 100644 index 0000000..380a868 --- /dev/null +++ b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + + + api key test + + + + It is not stored in this form, so be sure to copy it now. + +

+ You can authenticate a request using the Authorization HTTP header, example: +

+
+    curl -H "Authorization: Bearer 
+    api key test
+    " 
+    test/path
+    /api/dashboards/home
+  
+
+`; diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts new file mode 100644 index 0000000..5256ee3 --- /dev/null +++ b/public/app/features/api-keys/state/actions.ts @@ -0,0 +1,31 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { ApiKey, ThunkResult } from 'app/types'; +import { apiKeysLoaded, setSearchQuery } from './reducers'; + +export function addApiKey( + apiKey: ApiKey, + openModal: (key: string) => void, + includeExpired: boolean +): ThunkResult { + return async (dispatch) => { + const result = await getBackendSrv().post('/api/auth/keys', apiKey); + dispatch(setSearchQuery('')); + dispatch(loadApiKeys(includeExpired)); + openModal(result.key); + }; +} + +export function loadApiKeys(includeExpired: boolean): ThunkResult { + return async (dispatch) => { + const response = await getBackendSrv().get('/api/auth/keys?includeExpired=' + includeExpired); + dispatch(apiKeysLoaded(response)); + }; +} + +export function deleteApiKey(id: number, includeExpired: boolean): ThunkResult { + return async (dispatch) => { + getBackendSrv() + .delete(`/api/auth/keys/${id}`) + .then(() => dispatch(loadApiKeys(includeExpired))); + }; +} diff --git a/public/app/features/api-keys/state/reducers.test.ts b/public/app/features/api-keys/state/reducers.test.ts new file mode 100644 index 0000000..4ff9559 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.test.ts @@ -0,0 +1,27 @@ +import { apiKeysLoaded, apiKeysReducer, initialApiKeysState, setSearchQuery } from './reducers'; +import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { ApiKeysState } from '../../../types'; + +describe('API Keys reducer', () => { + it('should set keys', () => { + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(apiKeysLoaded(getMultipleMockKeys(4))) + .thenStateShouldEqual({ + ...initialApiKeysState, + keys: getMultipleMockKeys(4), + hasFetched: true, + }); + }); + + it('should set search query', () => { + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(setSearchQuery('test query')) + .thenStateShouldEqual({ + ...initialApiKeysState, + searchQuery: 'test query', + }); + }); +}); diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts new file mode 100644 index 0000000..b76d268 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.ts @@ -0,0 +1,30 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ApiKeysState } from 'app/types'; + +export const initialApiKeysState: ApiKeysState = { + keys: [], + searchQuery: '', + hasFetched: false, +}; + +const apiKeysSlice = createSlice({ + name: 'apiKeys', + initialState: initialApiKeysState, + reducers: { + apiKeysLoaded: (state, action): ApiKeysState => { + return { ...state, hasFetched: true, keys: action.payload }; + }, + setSearchQuery: (state, action): ApiKeysState => { + return { ...state, searchQuery: action.payload }; + }, + }, +}); + +export const { setSearchQuery, apiKeysLoaded } = apiKeysSlice.actions; + +export const apiKeysReducer = apiKeysSlice.reducer; + +export default { + apiKeys: apiKeysReducer, +}; diff --git a/public/app/features/api-keys/state/selectors.test.ts b/public/app/features/api-keys/state/selectors.test.ts new file mode 100644 index 0000000..5e9ba51 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.test.ts @@ -0,0 +1,25 @@ +import { getApiKeys } from './selectors'; +import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; +import { ApiKeysState } from 'app/types'; + +describe('API Keys selectors', () => { + describe('Get API Keys', () => { + const mockKeys = getMultipleMockKeys(5); + + it('should return all keys if no search query', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false }; + + const keys = getApiKeys(mockState); + + expect(keys).toEqual(mockKeys); + }); + + it('should filter keys if search query exists', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false }; + + const keys = getApiKeys(mockState); + + expect(keys.length).toEqual(1); + }); + }); +}); diff --git a/public/app/features/api-keys/state/selectors.ts b/public/app/features/api-keys/state/selectors.ts new file mode 100644 index 0000000..1fbfe80 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.ts @@ -0,0 +1,11 @@ +import { ApiKeysState } from 'app/types'; + +export const getApiKeysCount = (state: ApiKeysState) => state.keys.length; + +export const getApiKeys = (state: ApiKeysState) => { + const regex = RegExp(state.searchQuery, 'i'); + + return state.keys.filter((key) => { + return regex.test(key.name) || regex.test(key.role); + }); +}; diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx new file mode 100644 index 0000000..2d567f5 --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AddPanelWidgetUnconnected as AddPanelWidget, Props } from './AddPanelWidget'; +import { DashboardModel, PanelModel } from '../../state'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboard: {} as DashboardModel, + panel: {} as PanelModel, + addPanel: jest.fn() as any, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx new file mode 100644 index 0000000..23307b2 --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -0,0 +1,287 @@ +import React, { useMemo, useState } from 'react'; +import { connect, MapDispatchToProps } from 'react-redux'; +import { css, cx, keyframes } from '@emotion/css'; +import { chain, cloneDeep, defaults, find, sortBy } from 'lodash'; +import tinycolor from 'tinycolor2'; +import { locationService } from '@grafana/runtime'; +import { Icon, IconButton, styleMixins, useStyles } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { GrafanaTheme } from '@grafana/data'; + +import config from 'app/core/config'; +import store from 'app/core/store'; +import { addPanel } from 'app/features/dashboard/state/reducers'; +import { DashboardModel, PanelModel } from '../../state'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import { LibraryElementDTO } from '../../../library-panels/types'; +import { toPanelModelLibraryPanel } from '../../../library-panels/utils'; +import { + LibraryPanelsSearch, + LibraryPanelsSearchVariant, +} from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch'; + +export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } }; + +export interface OwnProps { + panel: PanelModel; + dashboard: DashboardModel; +} + +export interface DispatchProps { + addPanel: typeof addPanel; +} + +export type Props = OwnProps & DispatchProps; + +const getCopiedPanelPlugins = () => { + const panels = chain(config.panels) + .filter({ hideFromList: false }) + .map((item) => item) + .value(); + const copiedPanels = []; + + const copiedPanelJson = store.get(LS_PANEL_COPY_KEY); + if (copiedPanelJson) { + const copiedPanel = JSON.parse(copiedPanelJson); + const pluginInfo: any = find(panels, { id: copiedPanel.type }); + if (pluginInfo) { + const pluginCopy = cloneDeep(pluginInfo); + pluginCopy.name = copiedPanel.title; + pluginCopy.sort = -1; + pluginCopy.defaults = copiedPanel; + copiedPanels.push(pluginCopy); + } + } + + return sortBy(copiedPanels, 'sort'); +}; + +export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard }) => { + const [addPanelView, setAddPanelView] = useState(false); + + const onCancelAddPanel = (evt: React.MouseEvent) => { + evt.preventDefault(); + dashboard.removePanel(panel); + }; + + const onBack = () => { + setAddPanelView(false); + }; + + const onCreateNewPanel = () => { + const { gridPos } = panel; + + const newPanel: Partial = { + type: 'timeseries', + title: 'Panel Title', + gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h }, + }; + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + + locationService.partial({ editPanel: newPanel.id }); + }; + + const onPasteCopiedPanel = (panelPluginInfo: PanelPluginInfo) => { + const { gridPos } = panel; + + const newPanel: any = { + type: panelPluginInfo.id, + title: 'Panel Title', + gridPos: { + x: gridPos.x, + y: gridPos.y, + w: panelPluginInfo.defaults.gridPos.w, + h: panelPluginInfo.defaults.gridPos.h, + }, + }; + + // apply panel template / defaults + if (panelPluginInfo.defaults) { + defaults(newPanel, panelPluginInfo.defaults); + newPanel.title = panelPluginInfo.defaults.title; + store.delete(LS_PANEL_COPY_KEY); + } + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + }; + + const onAddLibraryPanel = (panelInfo: LibraryElementDTO) => { + const { gridPos } = panel; + + const newPanel: PanelModel = { + ...panelInfo.model, + gridPos, + libraryPanel: toPanelModelLibraryPanel(panelInfo), + }; + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + }; + + const onCreateNewRow = () => { + const newRow: any = { + type: 'row', + title: 'Row title', + gridPos: { x: 0, y: 0 }, + }; + + dashboard.addPanel(newRow); + dashboard.removePanel(panel); + }; + + const styles = useStyles(getStyles); + const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []); + + return ( +
+ + {addPanelView ? 'Add panel from panel library' : 'Add panel'} + + {addPanelView ? ( + + ) : ( +
+
+
onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}> + + Add an empty panel +
+
+ + Add a new row +
+
+
+
setAddPanelView(true)}> + + Add a panel from the panel library +
+ {copiedPanelPlugins.length === 1 && ( +
onPasteCopiedPanel(copiedPanelPlugins[0])}> + + Paste panel from clipboard +
+ )} +
+
+ )} +
+ ); +}; + +const mapDispatchToProps: MapDispatchToProps = { addPanel }; + +export const AddPanelWidget = connect(undefined, mapDispatchToProps)(AddPanelWidgetUnconnected); + +interface AddPanelWidgetHandleProps { + onCancel: (e: React.MouseEvent) => void; + onBack?: () => void; + children?: string; + styles: AddPanelStyles; +} + +const AddPanelWidgetHandle: React.FC = ({ children, onBack, onCancel, styles }) => { + return ( +
+ {onBack && ( +
+ +
+ )} + {!onBack && ( +
+ +
+ )} + {children && {children}} +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + const pulsate = keyframes` + 0% {box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${theme.colors.formFocusOutline};} + 50% {box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${tinycolor(theme.colors.formFocusOutline) + .darken(20) + .toHexString()};} + 100% {box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${theme.colors.formFocusOutline};} + `; + + return { + wrapper: css` + overflow: hidden; + outline: 2px dotted transparent; + outline-offset: 2px; + box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4; + animation: ${pulsate} 2s ease infinite; + `, + actionsRow: css` + display: flex; + flex-direction: row; + column-gap: ${theme.spacing.sm}; + height: 100%; + + > div { + justify-self: center; + cursor: pointer; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + color: ${theme.colors.text}; + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + &:hover { + background: ${styleMixins.hoverColor(theme.colors.bg2, theme)}; + } + + &:hover > #book-icon { + background: linear-gradient(#f05a28 30%, #fbca0a 99%); + } + } + `, + actionsWrapper: css` + display: flex; + flex-direction: column; + row-gap: ${theme.spacing.sm}; + padding: 0 ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm}; + height: 100%; + `, + headerRow: css` + display: flex; + align-items: center; + height: 38px; + flex-shrink: 0; + width: 100%; + font-size: ${theme.typography.size.md}; + font-weight: ${theme.typography.weight.semibold}; + padding-left: ${theme.spacing.sm}; + transition: background-color 0.1s ease-in-out; + cursor: move; + + &:hover { + background: ${theme.colors.bg2}; + } + `, + backButton: css` + display: flex; + align-items: center; + cursor: pointer; + padding-left: ${theme.spacing.xs}; + width: ${theme.spacing.xl}; + `, + noMargin: css` + margin: 0; + `, + }; +}; + +type AddPanelStyles = ReturnType; diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss new file mode 100644 index 0000000..ccb5d7f --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -0,0 +1,78 @@ +.add-panel-widget-container { + height: 100%; +} + +.add-panel-widget { + height: 100%; +} + +.add-panel-widget__header { + top: 0; + position: absolute; + padding: 0 8px; + display: flex; + align-items: center; + width: 100%; + cursor: move; + background: $page-header-bg; + box-shadow: $page-header-shadow; + border-bottom: 1px solid $page-header-border-color; + + .gicon { + font-size: 30px; + margin-right: $space-md; + } + + &:hover { + transition: background-color 0.1s ease-in-out; + background-color: $panel-header-hover-bg; + } +} + +.add-panel-widget__title { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + margin-right: $space-xl; +} + +.add-panel-widget__link { + margin: 0 $space-sm; + width: 170px; + height: 88px !important; + flex-direction: column !important; +} + +.add-panel-widget__icon { + margin-bottom: $space-sm; + + .gicon { + color: white; + height: 44px; + width: 53px; + position: relative; + left: 5px; + } +} + +.add-panel-widget__create { + display: inherit; + margin-bottom: $space-lg; + // this is to have the big button appear centered + margin-top: 55px; +} + +.add-panel-widget__actions { + display: inherit; +} + +.add-panel-widget__action { + margin: 0 $space-xs; +} + +.add-panel-widget__btn-container { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap new file mode 100644 index 0000000..2a129b3 --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+ + Add panel + +
+
+
+ + Add an empty panel +
+
+ + Add a new row +
+
+
+
+ + Add a panel from the panel library +
+
+
+
+`; diff --git a/public/app/features/dashboard/components/AddPanelWidget/index.ts b/public/app/features/dashboard/components/AddPanelWidget/index.ts new file mode 100644 index 0000000..b96948a --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/index.ts @@ -0,0 +1 @@ +export { AddPanelWidget } from './AddPanelWidget'; diff --git a/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx b/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx new file mode 100644 index 0000000..b01dec6 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { AnnotationQuery, DataSourceApi } from '@grafana/data'; +import { AngularComponent, getAngularLoader } from '@grafana/runtime'; + +export interface Props { + annotation: AnnotationQuery; + datasource: DataSourceApi; + onChange: (annotation: AnnotationQuery) => void; +} + +interface ScopeProps { + ctrl: { + currentDatasource: DataSourceApi; + currentAnnotation: AnnotationQuery; + ignoreNextWatcherFiring: boolean; + }; +} + +export class AngularEditorLoader extends React.PureComponent { + ref: HTMLDivElement | null = null; + angularComponent?: AngularComponent; + scopeProps?: ScopeProps; + + componentWillUnmount() { + if (this.angularComponent) { + this.angularComponent.destroy(); + } + } + + componentDidMount() { + if (this.ref) { + this.loadAngular(); + } + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.datasource !== this.props.datasource) { + this.loadAngular(); + } + + if (this.scopeProps && this.scopeProps.ctrl.currentAnnotation !== this.props.annotation) { + this.scopeProps.ctrl.ignoreNextWatcherFiring = true; + this.scopeProps.ctrl.currentAnnotation = this.props.annotation; + this.angularComponent?.digest(); + } + } + + loadAngular() { + if (this.angularComponent) { + this.angularComponent.destroy(); + this.scopeProps = undefined; + } + + const loader = getAngularLoader(); + const template = ` `; + const scopeProps = { + ctrl: { + currentDatasource: this.props.datasource, + currentAnnotation: this.props.annotation, + ignoreNextWatcherFiring: false, + }, + }; + + this.angularComponent = loader.load(this.ref, scopeProps, template); + this.angularComponent.digest(); + this.angularComponent.getScope().$watch(() => { + // To avoid recursive loop when the annotation is updated from outside angular in componentDidUpdate + if (scopeProps.ctrl.ignoreNextWatcherFiring) { + scopeProps.ctrl.ignoreNextWatcherFiring = false; + return; + } + + this.props.onChange(scopeProps.ctrl.currentAnnotation); + }); + + this.scopeProps = scopeProps; + } + + render() { + return
(this.ref = element)} />; + } +} diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx new file mode 100644 index 0000000..5a3b79c --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { Checkbox, CollapsableSection, ColorValueEditor, Field, HorizontalGroup, Input } from '@grafana/ui'; +import { DashboardModel } from '../../state/DashboardModel'; +import { AnnotationQuery, DataSourceInstanceSettings } from '@grafana/data'; +import { getDataSourceSrv, DataSourcePicker } from '@grafana/runtime'; +import { useAsync } from 'react-use'; +import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor'; +import { AngularEditorLoader } from './AngularEditorLoader'; + +export const newAnnotation: AnnotationQuery = { + name: 'New annotation', + enable: true, + datasource: null, + iconColor: 'red', +}; + +type Props = { + editIdx: number; + dashboard: DashboardModel; +}; + +export const AnnotationSettingsEdit: React.FC = ({ editIdx, dashboard }) => { + const [annotation, setAnnotation] = useState(editIdx !== null ? dashboard.annotations.list[editIdx] : newAnnotation); + + const { value: ds } = useAsync(() => { + return getDataSourceSrv().get(annotation.datasource); + }, [annotation.datasource]); + + const onUpdate = (annotation: AnnotationQuery) => { + const list = [...dashboard.annotations.list]; + list.splice(editIdx, 1, annotation); + setAnnotation(annotation); + dashboard.annotations.list = list; + }; + + const onNameChange = (ev: React.FocusEvent) => { + onUpdate({ + ...annotation, + name: ev.currentTarget.value, + }); + }; + + const onDataSourceChange = (ds: DataSourceInstanceSettings) => { + onUpdate({ + ...annotation, + datasource: ds.name, + }); + }; + + const onChange = (ev: React.FocusEvent) => { + const target = ev.currentTarget; + onUpdate({ + ...annotation, + [target.name]: target.type === 'checkbox' ? target.checked : target.value, + }); + }; + + const onColorChange = (color: string) => { + onUpdate({ + ...annotation, + iconColor: color, + }); + }; + + const isNewAnnotation = annotation.name === newAnnotation.name; + + return ( +
+ + + + + + + + + + + + + + + + {ds?.annotations && ( + + )} + {ds && !ds.annotations && } + +
+ ); +}; + +AnnotationSettingsEdit.displayName = 'AnnotationSettingsEdit'; diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx new file mode 100644 index 0000000..8fc7866 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { DeleteButton, Icon, IconButton, VerticalGroup } from '@grafana/ui'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { DashboardModel } from '../../state/DashboardModel'; +import { ListNewButton } from '../DashboardSettings/ListNewButton'; +import { arrayUtils } from '@grafana/data'; + +type Props = { + dashboard: DashboardModel; + onNew: () => void; + onEdit: (idx: number) => void; +}; + +export const AnnotationSettingsList: React.FC = ({ dashboard, onNew, onEdit }) => { + const [annotations, updateAnnotations] = useState(dashboard.annotations.list); + + const onMove = (idx: number, direction: number) => { + dashboard.annotations.list = arrayUtils.moveItemImmutably(annotations, idx, idx + direction); + updateAnnotations(dashboard.annotations.list); + }; + + const onDelete = (idx: number) => { + dashboard.annotations.list = [...annotations.slice(0, idx), ...annotations.slice(idx + 1)]; + updateAnnotations(dashboard.annotations.list); + }; + + const showEmptyListCTA = annotations.length === 0 || (annotations.length === 1 && annotations[0].builtIn); + + return ( + + {annotations.length > 0 && ( + + + + + + + + + + {dashboard.annotations.list.map((annotation, idx) => ( + + {!annotation.builtIn && ( + + )} + {annotation.builtIn && ( + + )} + + + + + + ))} + +
Query nameData source
onEdit(idx)}> +   {annotation.name} + onEdit(idx)}> +   {annotation.name} (Built-in) + onEdit(idx)}> + {annotation.datasource || 'Default'} + + {idx !== 0 && ( + onMove(idx, -1)} + /> + )} + + {dashboard.annotations.list.length > 1 && idx !== dashboard.annotations.list.length - 1 ? ( + onMove(idx, 1)} + /> + ) : null} + + onDelete(idx)} /> +
+ )} + {showEmptyListCTA && ( + Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines + and icons on all graph panels. When you hover over an annotation icon you can get event text & tags for + the event. You can add annotation events directly from grafana by holding CTRL or CMD + click on graph (or + drag region). These will be stored in Grafana's annotation database. +

+ Checkout the + Annotations documentation + for more information.`, + }} + /> + )} + {!showEmptyListCTA && New query} +
+ ); +}; diff --git a/public/app/features/dashboard/components/AnnotationSettings/index.tsx b/public/app/features/dashboard/components/AnnotationSettings/index.tsx new file mode 100644 index 0000000..b811059 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/index.tsx @@ -0,0 +1,2 @@ +export { AnnotationSettingsEdit } from './AnnotationSettingsEdit'; +export { AnnotationSettingsList } from './AnnotationSettingsList'; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts new file mode 100644 index 0000000..a8175b0 --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -0,0 +1,276 @@ +import { find } from 'lodash'; +import config from 'app/core/config'; +import { DashboardExporter } from './DashboardExporter'; +import { DashboardModel } from '../../state/DashboardModel'; +import { PanelPluginMeta } from '@grafana/data'; +import { variableAdapters } from '../../../variables/adapters'; +import { createConstantVariableAdapter } from '../../../variables/constant/adapter'; +import { createQueryVariableAdapter } from '../../../variables/query/adapter'; +import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter'; + +function getStub(arg: string) { + return Promise.resolve(stubs[arg || 'gfdb']); +} + +jest.mock('app/core/store', () => { + return { + getBool: jest.fn(), + getObject: jest.fn(), + }; +}); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getDataSourceSrv: () => ({ + get: jest.fn((arg) => getStub(arg)), + }), + config: { + buildInfo: {}, + panels: {}, + featureToggles: { + newVariables: false, + }, + }, +})); + +variableAdapters.register(createQueryVariableAdapter()); +variableAdapters.register(createConstantVariableAdapter()); +variableAdapters.register(createDataSourceVariableAdapter()); + +describe('given dashboard with repeated panels', () => { + let dash: any, exported: any; + + beforeEach((done) => { + dash = { + templating: { + list: [ + { + name: 'apps', + type: 'query', + datasource: 'gfdb', + current: { value: 'Asd', text: 'Asd' }, + options: [{ value: 'Asd', text: 'Asd' }], + }, + { + name: 'prefix', + type: 'constant', + current: { value: 'collectd', text: 'collectd' }, + options: [], + query: 'collectd', + }, + { + name: 'ds', + type: 'datasource', + query: 'other2', + current: { value: 'other2', text: 'other2' }, + options: [], + }, + ], + }, + annotations: { + list: [ + { + name: 'logs', + datasource: 'gfdb', + }, + ], + }, + panels: [ + { id: 6, datasource: 'gfdb', type: 'graph' }, + { id: 7 }, + { + id: 8, + datasource: '-- Mixed --', + targets: [{ datasource: 'other' }], + }, + { id: 9, datasource: '$ds' }, + { + id: 2, + repeat: 'apps', + datasource: 'gfdb', + type: 'graph', + }, + { id: 3, repeat: null, repeatPanelId: 2 }, + { + id: 4, + collapsed: true, + panels: [ + { id: 10, datasource: 'gfdb', type: 'table' }, + { id: 11 }, + { + id: 12, + datasource: '-- Mixed --', + targets: [{ datasource: 'other' }], + }, + { id: 13, datasource: '$ds' }, + { + id: 14, + repeat: 'apps', + datasource: 'gfdb', + type: 'heatmap', + }, + { id: 15, repeat: null, repeatPanelId: 14 }, + ], + }, + ], + }; + + config.buildInfo.version = '3.0.2'; + + config.panels['graph'] = { + id: 'graph', + name: 'Graph', + info: { version: '1.1.0' }, + } as PanelPluginMeta; + + config.panels['table'] = { + id: 'table', + name: 'Table', + info: { version: '1.1.1' }, + } as PanelPluginMeta; + + config.panels['heatmap'] = { + id: 'heatmap', + name: 'Heatmap', + info: { version: '1.1.2' }, + } as PanelPluginMeta; + + dash = new DashboardModel(dash, {}, () => dash.templating.list); + const exporter = new DashboardExporter(); + exporter.makeExportable(dash).then((clean) => { + exported = clean; + done(); + }); + }); + + it('should replace datasource refs', () => { + const panel = exported.panels[0]; + expect(panel.datasource).toBe('${DS_GFDB}'); + }); + + it('should replace datasource refs in collapsed row', () => { + const panel = exported.panels[5].panels[0]; + expect(panel.datasource).toBe('${DS_GFDB}'); + }); + + it('should replace datasource in variable query', () => { + expect(exported.templating.list[0].datasource).toBe('${DS_GFDB}'); + expect(exported.templating.list[0].options.length).toBe(0); + expect(exported.templating.list[0].current.value).toBe(undefined); + expect(exported.templating.list[0].current.text).toBe(undefined); + }); + + it('should replace datasource in annotation query', () => { + expect(exported.annotations.list[1].datasource).toBe('${DS_GFDB}'); + }); + + it('should add datasource as input', () => { + expect(exported.__inputs[0].name).toBe('DS_GFDB'); + expect(exported.__inputs[0].pluginId).toBe('testdb'); + expect(exported.__inputs[0].type).toBe('datasource'); + }); + + it('should add datasource to required', () => { + const require: any = find(exported.__requires, { name: 'TestDB' }); + expect(require.name).toBe('TestDB'); + expect(require.id).toBe('testdb'); + expect(require.type).toBe('datasource'); + expect(require.version).toBe('1.2.1'); + }); + + it('should not add built in datasources to required', () => { + const require: any = find(exported.__requires, { name: 'Mixed' }); + expect(require).toBe(undefined); + }); + + it('should add datasources used in mixed mode', () => { + const require: any = find(exported.__requires, { name: 'OtherDB' }); + expect(require).not.toBe(undefined); + }); + + it('should add graph panel to required', () => { + const require: any = find(exported.__requires, { name: 'Graph' }); + expect(require.name).toBe('Graph'); + expect(require.id).toBe('graph'); + expect(require.version).toBe('1.1.0'); + }); + + it('should add table panel to required', () => { + const require: any = find(exported.__requires, { name: 'Table' }); + expect(require.name).toBe('Table'); + expect(require.id).toBe('table'); + expect(require.version).toBe('1.1.1'); + }); + + it('should add heatmap panel to required', () => { + const require: any = find(exported.__requires, { name: 'Heatmap' }); + expect(require.name).toBe('Heatmap'); + expect(require.id).toBe('heatmap'); + expect(require.version).toBe('1.1.2'); + }); + + it('should add grafana version', () => { + const require: any = find(exported.__requires, { name: 'Grafana' }); + expect(require.type).toBe('grafana'); + expect(require.id).toBe('grafana'); + expect(require.version).toBe('3.0.2'); + }); + + it('should add constant template variables as inputs', () => { + const input: any = find(exported.__inputs, { name: 'VAR_PREFIX' }); + expect(input.type).toBe('constant'); + expect(input.label).toBe('prefix'); + expect(input.value).toBe('collectd'); + }); + + it('should templatize constant variables', () => { + const variable: any = find(exported.templating.list, { name: 'prefix' }); + expect(variable.query).toBe('${VAR_PREFIX}'); + expect(variable.current.text).toBe('${VAR_PREFIX}'); + expect(variable.current.value).toBe('${VAR_PREFIX}'); + expect(variable.options[0].text).toBe('${VAR_PREFIX}'); + expect(variable.options[0].value).toBe('${VAR_PREFIX}'); + }); + + it('should add datasources only use via datasource variable to requires', () => { + const require: any = find(exported.__requires, { name: 'OtherDB_2' }); + expect(require.id).toBe('other2'); + }); +}); + +// Stub responses +const stubs: { [key: string]: {} } = {}; +stubs['gfdb'] = { + name: 'gfdb', + meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' }, +}; + +stubs['other'] = { + name: 'other', + meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' }, +}; + +stubs['other2'] = { + name: 'other2', + meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' }, +}; + +stubs['-- Mixed --'] = { + name: 'mixed', + meta: { + id: 'mixed', + info: { version: '1.2.1' }, + name: 'Mixed', + builtIn: true, + }, +}; + +stubs['-- Grafana --'] = { + name: '-- Grafana --', + meta: { + id: 'grafana', + info: { version: '1.2.1' }, + name: 'grafana', + builtIn: true, + }, +}; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts new file mode 100644 index 0000000..c828a3d --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -0,0 +1,214 @@ +import { defaults, each, sortBy } from 'lodash'; + +import config from 'app/core/config'; +import { DashboardModel } from '../../state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state'; +import { PanelPluginMeta } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { VariableOption, VariableRefresh } from '../../../variables/types'; +import { isConstant, isQuery } from '../../../variables/guard'; + +interface Input { + name: string; + type: string; + label: string; + value: any; + description: string; +} + +interface Requires { + [key: string]: { + type: string; + id: string; + name: string; + version: string; + }; +} + +interface DataSources { + [key: string]: { + name: string; + label: string; + description: string; + type: string; + pluginId: string; + pluginName: string; + }; +} + +export class DashboardExporter { + makeExportable(dashboard: DashboardModel) { + // clean up repeated rows and panels, + // this is done on the live real dashboard instance, not on a clone + // so we need to undo this + // this is pretty hacky and needs to be changed + dashboard.cleanUpRepeats(); + + const saveModel = dashboard.getSaveModelClone(); + saveModel.id = null; + + // undo repeat cleanup + dashboard.processRepeats(); + + const inputs: Input[] = []; + const requires: Requires = {}; + const datasources: DataSources = {}; + const promises: Array> = []; + const variableLookup: { [key: string]: any } = {}; + + for (const variable of saveModel.getVariables()) { + variableLookup[variable.name] = variable; + } + + const templateizeDatasourceUsage = (obj: any) => { + let datasource: string = obj.datasource; + let datasourceVariable: any = null; + + // ignore data source properties that contain a variable + if (datasource && datasource.indexOf('$') === 0) { + datasourceVariable = variableLookup[datasource.substring(1)]; + if (datasourceVariable && datasourceVariable.current) { + datasource = datasourceVariable.current.value; + } + } + + promises.push( + getDataSourceSrv() + .get(datasource) + .then((ds) => { + if (ds.meta?.builtIn) { + return; + } + + // add data source type to require list + requires['datasource' + ds.meta?.id] = { + type: 'datasource', + id: ds.meta.id, + name: ds.meta.name, + version: ds.meta.info.version || '1.0.0', + }; + + // if used via variable we can skip templatizing usage + if (datasourceVariable) { + return; + } + + const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); + datasources[refName] = { + name: refName, + label: ds.name, + description: '', + type: 'datasource', + pluginId: ds.meta?.id, + pluginName: ds.meta?.name, + }; + + obj.datasource = '${' + refName + '}'; + }) + ); + }; + + const processPanel = (panel: PanelModel) => { + if (panel.datasource !== undefined && panel.datasource !== null) { + templateizeDatasourceUsage(panel); + } + + if (panel.targets) { + for (const target of panel.targets) { + if (target.datasource !== undefined) { + templateizeDatasourceUsage(target); + } + } + } + + const panelDef: PanelPluginMeta = config.panels[panel.type]; + if (panelDef) { + requires['panel' + panelDef.id] = { + type: 'panel', + id: panelDef.id, + name: panelDef.name, + version: panelDef.info.version, + }; + } + }; + + // check up panel data sources + for (const panel of saveModel.panels) { + processPanel(panel); + + // handle collapsed rows + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processPanel(rowPanel); + } + } + } + + // templatize template vars + for (const variable of saveModel.getVariables()) { + if (isQuery(variable)) { + templateizeDatasourceUsage(variable); + variable.options = []; + variable.current = ({} as unknown) as VariableOption; + variable.refresh = + variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad; + } + } + + // templatize annotations vars + for (const annotationDef of saveModel.annotations.list) { + templateizeDatasourceUsage(annotationDef); + } + + // add grafana version + requires['grafana'] = { + type: 'grafana', + id: 'grafana', + name: 'Grafana', + version: config.buildInfo.version, + }; + + return Promise.all(promises) + .then(() => { + each(datasources, (value: any) => { + inputs.push(value); + }); + + // templatize constants + for (const variable of saveModel.getVariables()) { + if (isConstant(variable)) { + const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase(); + inputs.push({ + name: refName, + type: 'constant', + label: variable.label || variable.name, + value: variable.query, + description: '', + }); + // update current and option + variable.query = '${' + refName + '}'; + variable.current = { + value: variable.query, + text: variable.query, + selected: false, + }; + variable.options = [variable.current]; + } + } + + // make inputs and requires a top thing + const newObj: { [key: string]: {} } = {}; + newObj['__inputs'] = inputs; + newObj['__requires'] = sortBy(requires, ['id']); + + defaults(newObj, saveModel); + return newObj; + }) + .catch((err) => { + console.error('Export failed:', err); + return { + error: err, + }; + }); + } +} diff --git a/public/app/features/dashboard/components/DashExportModal/index.ts b/public/app/features/dashboard/components/DashExportModal/index.ts new file mode 100644 index 0000000..f9afc4f --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/index.ts @@ -0,0 +1 @@ +export { DashboardExporter } from './DashboardExporter'; diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx new file mode 100644 index 0000000..e1a07e4 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -0,0 +1,273 @@ +// Libaries +import React, { PureComponent, FC, ReactNode } from 'react'; +import { connect, MapDispatchToProps } from 'react-redux'; +// Utils & Services +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; +// Components +import { DashNavButton } from './DashNavButton'; +import { DashNavTimeControls } from './DashNavTimeControls'; +import { ButtonGroup, ModalsController, ToolbarButton, PageToolbar } from '@grafana/ui'; +import { textUtil } from '@grafana/data'; +// State +import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; +// Types +import { DashboardModel } from '../../state'; +import { KioskMode, StoreState } from 'app/types'; +import { ShareModal } from 'app/features/dashboard/components/ShareModal'; +import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; +import { locationService } from '@grafana/runtime'; +import { toggleKioskMode } from 'app/core/navigation/kiosk'; +import { getDashboardSrv } from '../../services/DashboardSrv'; + +export interface OwnProps { + dashboard: DashboardModel; + isFullscreen: boolean; + kioskMode: KioskMode; + hideTimePicker: boolean; + onAddPanel: () => void; +} + +interface DispatchProps { + updateTimeZoneForSession: typeof updateTimeZoneForSession; +} + +interface DashNavButtonModel { + show: (props: Props) => boolean; + component: FC>; + index?: number | 'end'; +} + +const customLeftActions: DashNavButtonModel[] = []; +const customRightActions: DashNavButtonModel[] = []; + +export function addCustomLeftAction(content: DashNavButtonModel) { + customLeftActions.push(content); +} + +export function addCustomRightAction(content: DashNavButtonModel) { + customRightActions.push(content); +} + +type Props = OwnProps & DispatchProps; + +class DashNav extends PureComponent { + constructor(props: Props) { + super(props); + } + + onFolderNameClick = () => { + locationService.partial({ search: 'open', folder: 'current' }); + }; + + onClose = () => { + locationService.partial({ viewPanel: null }); + }; + + onToggleTVMode = () => { + toggleKioskMode(); + }; + + onOpenSettings = () => { + locationService.partial({ editview: 'settings' }); + }; + + onStarDashboard = () => { + const { dashboard } = this.props; + const dashboardSrv = getDashboardSrv(); + + dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then((newState: any) => { + dashboard.meta.isStarred = newState; + this.forceUpdate(); + }); + }; + + onPlaylistPrev = () => { + playlistSrv.prev(); + }; + + onPlaylistNext = () => { + playlistSrv.next(); + }; + + onPlaylistStop = () => { + playlistSrv.stop(); + this.forceUpdate(); + }; + + onDashboardNameClick = () => { + locationService.partial({ search: 'open' }); + }; + + addCustomContent(actions: DashNavButtonModel[], buttons: ReactNode[]) { + actions.map((action, index) => { + const Component = action.component; + const element = ; + typeof action.index === 'number' ? buttons.splice(action.index, 0, element) : buttons.push(element); + }); + } + + isPlaylistRunning() { + return playlistSrv.isPlaying; + } + + renderLeftActionsButton() { + const { dashboard, kioskMode } = this.props; + const { canStar, canShare, isStarred } = dashboard.meta; + const buttons: ReactNode[] = []; + + if (kioskMode !== KioskMode.Off || this.isPlaylistRunning()) { + return []; + } + + if (canStar) { + buttons.push( + + ); + } + + if (canShare) { + buttons.push( + + {({ showModal, hideModal }) => ( + { + showModal(ShareModal, { + dashboard, + onDismiss: hideModal, + }); + }} + /> + )} + + ); + } + + this.addCustomContent(customLeftActions, buttons); + return buttons; + } + + renderPlaylistControls() { + return ( + + + Stop playlist + + + ); + } + + renderTimeControls() { + const { dashboard, updateTimeZoneForSession, hideTimePicker } = this.props; + + if (hideTimePicker) { + return null; + } + + return ( + + ); + } + + renderRightActionsButton() { + const { dashboard, onAddPanel, isFullscreen, kioskMode } = this.props; + const { canEdit, showSettings } = dashboard.meta; + const { snapshot } = dashboard; + const snapshotUrl = snapshot && snapshot.originalUrl; + const buttons: ReactNode[] = []; + const tvButton = ( + + ); + + if (this.isPlaylistRunning()) { + return [this.renderPlaylistControls(), this.renderTimeControls()]; + } + + if (kioskMode === KioskMode.TV) { + return [this.renderTimeControls(), tvButton]; + } + + if (canEdit && !isFullscreen) { + buttons.push(); + buttons.push( + + {({ showModal, hideModal }) => ( + { + showModal(SaveDashboardModalProxy, { + dashboard, + onDismiss: hideModal, + }); + }} + /> + )} + + ); + } + + if (snapshotUrl) { + buttons.push( + this.gotoSnapshotOrigin(snapshotUrl)} + icon="link" + key="button-snapshot" + /> + ); + } + + if (showSettings) { + buttons.push( + + ); + } + + this.addCustomContent(customRightActions, buttons); + + buttons.push(this.renderTimeControls()); + buttons.push(tvButton); + return buttons; + } + + gotoSnapshotOrigin(snapshotUrl: string) { + window.location.href = textUtil.sanitizeUrl(snapshotUrl); + } + + render() { + const { dashboard, isFullscreen } = this.props; + const onGoBack = isFullscreen ? this.onClose : undefined; + + return ( + + {this.renderRightActionsButton()} + + ); + } +} + +const mapStateToProps = (state: StoreState) => ({}); + +const mapDispatchToProps: MapDispatchToProps = { + updateTimeZoneForSession, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DashNav); diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx new file mode 100644 index 0000000..5f8e3e6 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx @@ -0,0 +1,44 @@ +// Libraries +import React, { FunctionComponent, MouseEvent } from 'react'; +import { css } from '@emotion/css'; +// Components +import { IconName, IconType, IconSize, IconButton, useTheme, stylesFactory } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; + +interface Props { + icon?: IconName; + tooltip: string; + onClick?: (event: MouseEvent) => void; + href?: string; + children?: React.ReactNode; + iconType?: IconType; + iconSize?: IconSize; +} + +export const DashNavButton: FunctionComponent = ({ icon, iconType, iconSize, tooltip, onClick, children }) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( +
+ {icon && ( + + )} + {children} +
+ ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + noBorderContainer: css` + padding: 0 ${theme.spacing.xs}; + display: flex; + `, +})); diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx new file mode 100644 index 0000000..2643b65 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -0,0 +1,122 @@ +// Libraries +import React, { Component } from 'react'; +import { dateMath, TimeRange, TimeZone } from '@grafana/data'; +import { css } from '@emotion/css'; + +// Types +import { DashboardModel } from '../../state'; +import { CoreEvents } from 'app/types'; + +// Components +import { defaultIntervals, RefreshPicker, stylesFactory } from '@grafana/ui'; +import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; + +// Utils & Services +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { appEvents } from 'app/core/core'; +import { ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../../types/events'; + +export interface Props { + dashboard: DashboardModel; + onChangeTimeZone: (timeZone: TimeZone) => void; +} + +export class DashNavTimeControls extends Component { + componentDidMount() { + // Only reason for this is that sometimes time updates can happen via redux location changes + // and this happens before timeSrv has had chance to update state (as it listens to angular route-updated) + // This can be removed after timeSrv listens redux location + this.props.dashboard.on(CoreEvents.timeRangeUpdated, this.triggerForceUpdate); + } + + componentWillUnmount() { + this.props.dashboard.off(CoreEvents.timeRangeUpdated, this.triggerForceUpdate); + } + + triggerForceUpdate = () => { + this.forceUpdate(); + }; + + onChangeRefreshInterval = (interval: string) => { + getTimeSrv().setAutoRefresh(interval); + this.forceUpdate(); + }; + + onRefresh = () => { + getTimeSrv().refreshDashboard(); + return Promise.resolve(); + }; + + onMoveBack = () => { + appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left)); + }; + + onMoveForward = () => { + appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right)); + }; + + onChangeTimePicker = (timeRange: TimeRange) => { + const { dashboard } = this.props; + const panel = dashboard.timepicker; + const hasDelay = panel.nowDelay && timeRange.raw.to === 'now'; + + const adjustedFrom = dateMath.isMathString(timeRange.raw.from) ? timeRange.raw.from : timeRange.from; + const adjustedTo = dateMath.isMathString(timeRange.raw.to) ? timeRange.raw.to : timeRange.to; + const nextRange = { + from: adjustedFrom, + to: hasDelay ? 'now-' + panel.nowDelay : adjustedTo, + }; + + getTimeSrv().setTime(nextRange); + }; + + onChangeTimeZone = (timeZone: TimeZone) => { + this.props.dashboard.timezone = timeZone; + this.props.onChangeTimeZone(timeZone); + this.onRefresh(); + }; + + onZoom = () => { + appEvents.publish(new ZoomOutEvent(2)); + }; + + render() { + const { dashboard } = this.props; + const { refresh_intervals } = dashboard.timepicker; + const intervals = getTimeSrv().getValidIntervals(refresh_intervals || defaultIntervals); + + const timePickerValue = getTimeSrv().timeRange(); + const timeZone = dashboard.getTimezone(); + const styles = getStyles(); + + return ( +
+ + +
+ ); + } +} + +const getStyles = stylesFactory(() => { + return { + container: css` + position: relative; + display: flex; + `, + }; +}); diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts new file mode 100644 index 0000000..be07fd0 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -0,0 +1,2 @@ +import DashNav from './DashNav'; +export { DashNav }; diff --git a/public/app/features/dashboard/components/DashboardLoading/DashboardFailed.tsx b/public/app/features/dashboard/components/DashboardLoading/DashboardFailed.tsx new file mode 100644 index 0000000..75f2c39 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardLoading/DashboardFailed.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { css } from 'emotion'; +import { Alert, useStyles } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { DashboardInitError, AppNotificationSeverity } from 'app/types'; +import { getMessageFromError } from 'app/core/utils/errors'; + +export interface Props { + initError?: DashboardInitError; +} + +export const DashboardFailed = ({ initError }: Props) => { + const styles = useStyles(getStyles); + if (!initError) { + return null; + } + + return ( +
+ + {getMessageFromError(initError.error)} + +
+ ); +}; + +export const getStyles = (theme: GrafanaTheme) => { + return { + dashboardLoading: css` + height: 60vh; + display: flex; + align-items: center; + justify-content: center; + `, + }; +}; diff --git a/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx b/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx new file mode 100644 index 0000000..40e168b --- /dev/null +++ b/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { css } from 'emotion'; +import { Button, HorizontalGroup, Spinner, useStyles, VerticalGroup } from '@grafana/ui'; +import { locationService } from '@grafana/runtime'; +import { GrafanaTheme } from '@grafana/data'; +import { DashboardInitPhase } from 'app/types'; + +export interface Props { + initPhase: DashboardInitPhase; +} + +export const DashboardLoading = ({ initPhase }: Props) => { + const styles = useStyles(getStyles); + const cancelVariables = () => { + locationService.push('/'); + }; + + return ( +
+
+ + + {initPhase} + {' '} + + + + +
+
+ ); +}; + +export const getStyles = (theme: GrafanaTheme) => { + return { + dashboardLoading: css` + height: 60vh; + display: flex; + align-items: center; + justify-content: center; + `, + dashboardLoadingText: css` + font-size: ${theme.typography.size.lg}; + `, + }; +}; diff --git a/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx new file mode 100644 index 0000000..a93dcdc --- /dev/null +++ b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx @@ -0,0 +1,122 @@ +import React, { PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import { Tooltip, Icon, Button } from '@grafana/ui'; +import { SlideDown } from 'app/core/components/Animations/SlideDown'; +import { StoreState } from 'app/types'; +import { DashboardAcl, PermissionLevel, NewDashboardAclItem } from 'app/types/acl'; +import { + getDashboardPermissions, + addDashboardPermission, + removeDashboardPermission, + updateDashboardPermission, +} from '../../state/actions'; +import { DashboardModel } from '../../state/DashboardModel'; +import PermissionList from 'app/core/components/PermissionList/PermissionList'; +import AddPermission from 'app/core/components/PermissionList/AddPermission'; +import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; + +const mapStateToProps = (state: StoreState) => ({ + permissions: state.dashboard.permissions, +}); + +const mapDispatchToProps = { + getDashboardPermissions, + addDashboardPermission, + removeDashboardPermission, + updateDashboardPermission, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export interface OwnProps { + dashboard: DashboardModel; +} + +export type Props = OwnProps & ConnectedProps; + +export interface State { + isAdding: boolean; +} + +export class DashboardPermissionsUnconnected extends PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + isAdding: false, + }; + } + + componentDidMount() { + this.props.getDashboardPermissions(this.props.dashboard.id); + } + + onOpenAddPermissions = () => { + this.setState({ isAdding: true }); + }; + + onRemoveItem = (item: DashboardAcl) => { + this.props.removeDashboardPermission(this.props.dashboard.id, item); + }; + + onPermissionChanged = (item: DashboardAcl, level: PermissionLevel) => { + this.props.updateDashboardPermission(this.props.dashboard.id, item, level); + }; + + onAddPermission = (newItem: NewDashboardAclItem) => { + return this.props.addDashboardPermission(this.props.dashboard.id, newItem); + }; + + onCancelAddPermission = () => { + this.setState({ isAdding: false }); + }; + + getFolder() { + const { dashboard } = this.props; + + return { + id: dashboard.meta.folderId, + title: dashboard.meta.folderTitle, + url: dashboard.meta.folderUrl, + }; + } + + render() { + const { + permissions, + dashboard: { + meta: { hasUnsavedFolderChange }, + }, + } = this.props; + const { isAdding } = this.state; + + return hasUnsavedFolderChange ? ( +
You have changed a folder, please save to view permissions.
+ ) : ( +
+
+

Permissions

+ }> + + +
+ +
+ + + + +
+ ); + } +} + +export const DashboardPermissions = connector(DashboardPermissionsUnconnected); diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx new file mode 100644 index 0000000..df944b0 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DashboardRow } from './DashboardRow'; +import { PanelModel } from '../../state/PanelModel'; + +describe('DashboardRow', () => { + let wrapper: any, panel: PanelModel, dashboardMock: any; + + beforeEach(() => { + dashboardMock = { + toggleRow: jest.fn(), + on: jest.fn(), + meta: { + canEdit: true, + }, + }; + + panel = new PanelModel({ collapsed: false }); + wrapper = mount(); + }); + + it('Should not have collapsed class when collaped is false', () => { + expect(wrapper.find('.dashboard-row')).toHaveLength(1); + expect(wrapper.find('.dashboard-row--collapsed')).toHaveLength(0); + }); + + it('Should collapse after clicking title', () => { + wrapper.find('.dashboard-row__title').simulate('click'); + + expect(wrapper.find('.dashboard-row--collapsed')).toHaveLength(1); + expect(dashboardMock.toggleRow.mock.calls).toHaveLength(1); + }); + + it('should have two actions as admin', () => { + expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2); + }); + + it('should not show row drag handle when cannot edit', () => { + dashboardMock.meta.canEdit = false; + wrapper = mount(); + 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 = mount(); + expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx new file mode 100644 index 0000000..687eedb --- /dev/null +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Icon } from '@grafana/ui'; +import { PanelModel } from '../../state/PanelModel'; +import { DashboardModel } from '../../state/DashboardModel'; +import appEvents from 'app/core/app_events'; +import { CoreEvents } from 'app/types'; +import { RowOptionsButton } from '../RowOptions/RowOptionsButton'; +import { getTemplateSrv } from '@grafana/runtime'; +import { ShowConfirmModalEvent } from '../../../../types/events'; + +export interface DashboardRowProps { + panel: PanelModel; + dashboard: DashboardModel; +} + +export class DashboardRow extends React.Component { + constructor(props: DashboardRowProps) { + super(props); + + this.state = { + collapsed: this.props.panel.collapsed, + }; + + this.props.dashboard.on(CoreEvents.templateVariableValueUpdated, this.onVariableUpdated); + } + + componentWillUnmount() { + this.props.dashboard.off(CoreEvents.templateVariableValueUpdated, this.onVariableUpdated); + } + + onVariableUpdated = () => { + this.forceUpdate(); + }; + + onToggle = () => { + this.props.dashboard.toggleRow(this.props.panel); + + this.setState((prevState: any) => { + return { collapsed: !prevState.collapsed }; + }); + }; + + onUpdate = (title: string, repeat: string | undefined) => { + this.props.panel['title'] = title; + this.props.panel['repeat'] = repeat; + this.props.panel.render(); + this.props.dashboard.processRepeats(); + this.forceUpdate(); + }; + + onDelete = () => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Delete row', + text: 'Are you sure you want to remove this row and all its panels?', + altActionText: 'Delete row only', + icon: 'trash-alt', + onConfirm: () => { + this.props.dashboard.removeRow(this.props.panel, true); + }, + onAltAction: () => { + this.props.dashboard.removeRow(this.props.panel, false); + }, + }) + ); + }; + + render() { + const classes = classNames({ + 'dashboard-row': true, + 'dashboard-row--collapsed': this.state.collapsed, + }); + + const title = getTemplateSrv().replace(this.props.panel.title, this.props.panel.scopedVars, 'text'); + const count = this.props.panel.panels ? this.props.panel.panels.length : 0; + const panels = count === 1 ? 'panel' : 'panels'; + const canEdit = this.props.dashboard.meta.canEdit === true; + + return ( +
+ + + {title} + + ({count} {panels}) + + + {canEdit && ( +
+ + + + +
+ )} + {this.state.collapsed === true && ( +
+   +
+ )} + {canEdit &&
} +
+ ); + } +} diff --git a/public/app/features/dashboard/components/DashboardRow/index.ts b/public/app/features/dashboard/components/DashboardRow/index.ts new file mode 100644 index 0000000..3f71e03 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardRow/index.ts @@ -0,0 +1 @@ +export { DashboardRow } from './DashboardRow'; diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx new file mode 100644 index 0000000..46f9278 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { selectors } from '@grafana/e2e-selectors'; +import { setDataSourceSrv } from '@grafana/runtime'; +import { setAngularLoader } from 'app/core/services/AngularLoader'; +import { AnnotationsSettings } from './AnnotationsSettings'; + +describe('AnnotationsSettings', () => { + let dashboard: any; + const datasources: Record = { + Grafana: { + name: 'Grafana', + meta: { + type: 'datasource', + name: 'Grafana', + id: 'grafana', + info: { + logos: { + small: 'public/img/icn-datasource.svg', + }, + }, + }, + }, + Testdata: { + name: 'Testdata', + id: 4, + meta: { + type: 'datasource', + name: 'TestData', + id: 'testdata', + info: { + logos: { + small: 'public/app/plugins/datasource/testdata/img/testdata.svg', + }, + }, + }, + }, + Prometheus: { + name: 'Prometheus', + id: 33, + meta: { + type: 'datasource', + name: 'Prometheus', + id: 'prometheus', + info: { + logos: { + small: 'public/app/plugins/datasource/prometheus/img/prometheus_logo.svg', + }, + }, + }, + }, + }; + + const getTableBody = () => screen.getAllByRole('rowgroup')[1]; + const getTableBodyRows = () => within(getTableBody()).getAllByRole('row'); + + beforeAll(() => { + setDataSourceSrv({ + getList() { + return Object.values(datasources).map((d) => d); + }, + getInstanceSettings(name: string) { + return name + ? { + name: datasources[name].name, + value: datasources[name].name, + meta: datasources[name].meta, + } + : { + name: datasources.Testdata.name, + value: datasources.Testdata.name, + meta: datasources.Testdata.meta, + }; + }, + get(name: string) { + return Promise.resolve(name ? datasources[name] : datasources.Testdata); + }, + } as any); + + // @ts-ignore + setAngularLoader({ + load: () => ({ + destroy: jest.fn(), + digest: jest.fn(), + getScope: () => ({ $watch: () => {} }), + }), + }); + }); + + beforeEach(() => { + dashboard = { + id: 74, + version: 7, + annotations: { + list: [ + { + builtIn: 1, + datasource: 'Grafana', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + links: [], + }; + }); + + test('it renders a header and cta if no annotations or only builtIn annotation', () => { + render(); + + expect(screen.getByRole('heading', { name: /annotations/i })).toBeInTheDocument(); + expect(screen.queryByRole('table')).toBeInTheDocument(); + expect( + screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana cancel delete/i }) + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /annotations documentation/i })).toBeInTheDocument(); + + userEvent.click(screen.getByRole('cell', { name: /annotations & alerts \(built\-in\)/i })); + + const heading = screen.getByRole('heading', { + name: /annotations edit/i, + }); + const nameInput = screen.getByRole('textbox', { name: /name/i }); + + expect(heading).toBeInTheDocument(); + + userEvent.clear(nameInput); + userEvent.type(nameInput, 'My Annotation'); + + expect(screen.queryByText(/grafana/i)).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /hidden/i })).toBeChecked(); + + userEvent.click(within(heading).getByText(/annotations/i)); + + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /my annotation \(built\-in\) grafana cancel delete/i })).toBeInTheDocument(); + expect( + screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /new query/i })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: /delete/i })); + + expect(screen.queryAllByRole('row').length).toBe(0); + expect( + screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) + ).toBeInTheDocument(); + }); + + test('it renders a sortable table of annotations', () => { + const annotationsList = [ + ...dashboard.annotations.list, + { + builtIn: 0, + datasource: 'Prometheus', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotation 2', + type: 'dashboard', + }, + { + builtIn: 0, + datasource: 'Prometheus', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotation 3', + type: 'dashboard', + }, + ]; + const dashboardWithAnnotations = { + ...dashboard, + annotations: { + list: [...annotationsList], + }, + }; + render(); + // Check that we have sorting buttons + expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument(); + expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument(); + + expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument(); + expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument(); + + expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument(); + expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-down' })).not.toBeInTheDocument(); + + // Check the original order + expect(within(getTableBodyRows()[0]).queryByText(/annotations & alerts/i)).toBeInTheDocument(); + expect(within(getTableBodyRows()[1]).queryByText(/annotation 2/i)).toBeInTheDocument(); + expect(within(getTableBodyRows()[2]).queryByText(/annotation 3/i)).toBeInTheDocument(); + + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[0]); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[1]); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-up' })[0]); + + // Checking if it has changed the sorting accordingly + expect(within(getTableBodyRows()[0]).queryByText(/annotation 3/i)).toBeInTheDocument(); + expect(within(getTableBodyRows()[1]).queryByText(/annotation 2/i)).toBeInTheDocument(); + expect(within(getTableBodyRows()[2]).queryByText(/annotations & alerts/i)).toBeInTheDocument(); + }); + + test('it renders a form for adding/editing annotations', () => { + render(); + + userEvent.click(screen.getByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))); + + const heading = screen.getByRole('heading', { + name: /annotations edit/i, + }); + const nameInput = screen.getByRole('textbox', { name: /name/i }); + + expect(heading).toBeInTheDocument(); + + userEvent.clear(nameInput); + userEvent.type(nameInput, 'My Prometheus Annotation'); + + userEvent.click(screen.getByText(/testdata/i)); + + expect(screen.queryByText(/prometheus/i)).toBeVisible(); + expect(screen.queryAllByText(/testdata/i)).toHaveLength(2); + + userEvent.click(screen.getByText(/prometheus/i)); + + expect(screen.getByRole('checkbox', { name: /hidden/i })).not.toBeChecked(); + + userEvent.click(within(heading).getByText(/annotations/i)); + + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2); + expect( + screen.queryByRole('row', { name: /my prometheus annotation prometheus cancel delete/i }) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /new query/i })).toBeInTheDocument(); + expect( + screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) + ).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: /new query/i })); + + userEvent.click(within(screen.getByRole('heading', { name: /annotations edit/i })).getByText(/annotations/i)); + + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(3); + + userEvent.click(screen.getAllByRole('button', { name: /delete/i })[1]); + + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx new file mode 100644 index 0000000..93fb799 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { DashboardModel } from '../../state/DashboardModel'; +import { AnnotationSettingsEdit, AnnotationSettingsList } from '../AnnotationSettings'; +import { newAnnotation } from '../AnnotationSettings/AnnotationSettingsEdit'; +import { DashboardSettingsHeader } from './DashboardSettingsHeader'; + +interface Props { + dashboard: DashboardModel; +} + +export const AnnotationsSettings: React.FC = ({ dashboard }) => { + const [editIdx, setEditIdx] = useState(null); + + const onGoBack = () => { + setEditIdx(null); + }; + + const onNew = () => { + dashboard.annotations.list = [...dashboard.annotations.list, { ...newAnnotation }]; + setEditIdx(dashboard.annotations.list.length - 1); + }; + + const onEdit = (idx: number) => { + setEditIdx(idx); + }; + + const isEditing = editIdx !== null; + + return ( + <> + + {!isEditing && } + {isEditing && } + + ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx new file mode 100644 index 0000000..d400a42 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { defaultIntervals } from '@grafana/ui'; + +import { AutoRefreshIntervals, getValidIntervals, Props, validateIntervals } from './AutoRefreshIntervals'; +import { TimeSrv } from '../../services/TimeSrv'; + +const setupTestContext = (options: Partial) => { + const defaults: Props = { + refreshIntervals: ['1s', '5s', '10s'], + onRefreshIntervalChange: jest.fn(), + getIntervalsFunc: (intervals) => intervals, + validateIntervalsFunc: () => null, + }; + + const props = { ...defaults, ...options }; + const { rerender } = render(); + + return { rerender, props }; +}; + +describe('AutoRefreshIntervals', () => { + describe('when component is mounted with refreshIntervals', () => { + it('then supplied intervals should be shown', () => { + setupTestContext({ getIntervalsFunc: () => ['5s', '10s'] }); // remove 1s entry to validate we're calling getIntervalsFunc + + expect(screen.getByRole('textbox')).toHaveValue('5s,10s'); + }); + }); + + describe('when component is mounted without refreshIntervals', () => { + it('then default intervals should be shown', () => { + setupTestContext({ refreshIntervals: (null as unknown) as string[] }); + + expect(screen.getByRole('textbox')).toHaveValue('5s,10s,30s,1m,5m,15m,30m,1h,2h,1d'); + }); + }); + + describe('when component is updated from Angular', () => { + it('then intervals should be updated', () => { + const { rerender, props } = setupTestContext({}); + const newProps = { ...props, renderCount: 1, refreshIntervals: ['2s', '6s', '11s'] }; + + rerender(); + + expect(screen.getByRole('textbox')).toHaveValue('2s,6s,11s'); + }); + }); + + describe('when input loses focus and intervals are valid', () => { + it('then onRefreshIntervalChange should be called', () => { + const { props } = setupTestContext({ validateIntervalsFunc: () => null }); + + userEvent.type(screen.getByRole('textbox'), ',30s'); + userEvent.tab(); + + expect(screen.getByRole('textbox')).toHaveValue('1s,5s,10s,30s'); + expect(props.onRefreshIntervalChange).toHaveBeenCalledTimes(1); + expect(props.onRefreshIntervalChange).toHaveBeenCalledWith(['1s', '5s', '10s', '30s']); + }); + }); + + describe('when input loses focus and intervals are invalid', () => { + it('then onRefreshIntervalChange should not be called', () => { + const { props } = setupTestContext({ validateIntervalsFunc: () => 'Not valid' }); + + userEvent.type(screen.getByRole('textbox'), ',30q'); + userEvent.tab(); + + expect(screen.getByRole('textbox')).toHaveValue('1s,5s,10s,30q'); + expect(props.onRefreshIntervalChange).toHaveBeenCalledTimes(0); + }); + }); + + describe('when input loses focus and previous intervals were invalid', () => { + it('then onRefreshIntervalChange should be called', () => { + const validateIntervalsFunc = jest.fn().mockReturnValueOnce('Not valid').mockReturnValue(null); + const { props } = setupTestContext({ validateIntervalsFunc }); + + userEvent.type(screen.getByRole('textbox'), ',30q'); + userEvent.tab(); + userEvent.type(screen.getByRole('textbox'), '{backspace}s'); + userEvent.tab(); + + expect(screen.getByRole('textbox')).toHaveValue('1s,5s,10s,30s'); + expect(props.onRefreshIntervalChange).toHaveBeenCalledTimes(1); + expect(props.onRefreshIntervalChange).toHaveBeenCalledWith(['1s', '5s', '10s', '30s']); + }); + }); +}); + +describe('getValidIntervals', () => { + describe('when called with empty intervals', () => { + it('then is should all non empty intervals', () => { + const emptyIntervals = ['', '5s', ' ', '10s', ' ']; + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = getValidIntervals(emptyIntervals, dependencies); + + expect(result).toEqual(['5s', '10s']); + }); + }); + + describe('when called with duplicate intervals', () => { + it('then is should return no duplicates', () => { + const duplicateIntervals = ['5s', '10s', '1m', '5s', '30s', '10s', '5s', '2m']; + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = getValidIntervals(duplicateIntervals, dependencies); + + expect(result).toEqual(['5s', '10s', '1m', '30s', '2m']); + }); + }); + + describe('when called with untrimmed intervals', () => { + it('then is should return trimmed intervals', () => { + const duplicateIntervals = [' 5s', '10s ', ' 1m ', ' 3 0 s ', ' 2 m ']; + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = getValidIntervals(duplicateIntervals, dependencies); + + expect(result).toEqual(['5s', '10s', '1m', '30s', '2m']); + }); + }); +}); + +describe('validateIntervals', () => { + describe('when getValidIntervals does not throw', () => { + it('then it should return null', () => { + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = validateIntervals(defaultIntervals, dependencies); + + expect(result).toBe(null); + }); + }); + + describe('when getValidIntervals throws', () => { + it('then it should return the exception message', () => { + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: () => { + throw new Error('Some error'); + }, + } as unknown) as TimeSrv), + }; + + const result = validateIntervals(defaultIntervals, dependencies); + + expect(result).toEqual('Some error'); + }); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx new file mode 100644 index 0000000..891c560 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx @@ -0,0 +1,93 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { Input, defaultIntervals, Field } from '@grafana/ui'; + +import { getTimeSrv } from '../../services/TimeSrv'; + +export interface Props { + refreshIntervals: string[]; + onRefreshIntervalChange: (interval: string[]) => void; + getIntervalsFunc?: typeof getValidIntervals; + validateIntervalsFunc?: typeof validateIntervals; +} + +export const AutoRefreshIntervals: FC = ({ + refreshIntervals, + onRefreshIntervalChange, + getIntervalsFunc = getValidIntervals, + validateIntervalsFunc = validateIntervals, +}) => { + const [intervals, setIntervals] = useState(getIntervalsFunc(refreshIntervals ?? defaultIntervals)); + const [invalidIntervalsMessage, setInvalidIntervalsMessage] = useState(null); + + useEffect(() => { + const intervals = getIntervalsFunc(refreshIntervals ?? defaultIntervals); + setIntervals(intervals); + }, [getIntervalsFunc, refreshIntervals]); + + const intervalsString = useMemo(() => { + if (!Array.isArray(intervals)) { + return ''; + } + + return intervals.join(','); + }, [intervals]); + + const onIntervalsChange = useCallback( + (event: React.FormEvent) => { + const newIntervals = event.currentTarget.value ? event.currentTarget.value.split(',') : []; + + setIntervals(newIntervals); + }, + [setIntervals] + ); + + const onIntervalsBlur = useCallback( + (event: React.FormEvent) => { + const invalidMessage = validateIntervalsFunc(intervals); + + if (invalidMessage === null) { + // only refresh dashboard JSON if intervals are valid + onRefreshIntervalChange(getIntervalsFunc(intervals)); + } + + setInvalidIntervalsMessage(invalidMessage); + }, + [getIntervalsFunc, intervals, onRefreshIntervalChange, validateIntervalsFunc] + ); + + return ( + + + + ); +}; + +export const validateIntervals = ( + intervals: string[], + dependencies: { getTimeSrv: typeof getTimeSrv } = { getTimeSrv } +): string | null => { + try { + getValidIntervals(intervals, dependencies); + return null; + } catch (err) { + return err.message; + } +}; + +export const getValidIntervals = ( + intervals: string[], + dependencies: { getTimeSrv: typeof getTimeSrv } = { getTimeSrv } +) => { + const cleanIntervals = intervals.filter((i) => i.trim() !== '').map((interval) => interval.replace(/\s+/g, '')); + return [...new Set(dependencies.getTimeSrv().getValidIntervals(cleanIntervals))]; +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx new file mode 100644 index 0000000..33de89e --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -0,0 +1,201 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from '@emotion/css'; +import { selectors } from '@grafana/e2e-selectors'; +import { Button, CustomScrollbar, Icon, IconName, PageToolbar, stylesFactory } from '@grafana/ui'; +import config from 'app/core/config'; +import { contextSrv } from 'app/core/services/context_srv'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { DashboardModel } from '../../state/DashboardModel'; +import { SaveDashboardButton, SaveDashboardAsButton } from '../SaveDashboard/SaveDashboardButton'; +import { VariableEditorContainer } from '../../../variables/editor/VariableEditorContainer'; +import { DashboardPermissions } from '../DashboardPermissions/DashboardPermissions'; +import { GeneralSettings } from './GeneralSettings'; +import { AnnotationsSettings } from './AnnotationsSettings'; +import { LinksSettings } from './LinksSettings'; +import { VersionsSettings } from './VersionsSettings'; +import { JsonEditorSettings } from './JsonEditorSettings'; +import { GrafanaTheme2 } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +export interface Props { + dashboard: DashboardModel; + editview: string; +} + +export interface SettingsPage { + id: string; + title: string; + icon: IconName; + render: () => React.ReactNode; +} + +export class DashboardSettings extends PureComponent { + onClose = () => { + locationService.partial({ editview: null }); + }; + + onChangePage = (editview: string) => { + locationService.partial({ editview }); + }; + + getPages(): SettingsPage[] { + const { dashboard } = this.props; + const pages: SettingsPage[] = []; + + if (dashboard.meta.canEdit) { + pages.push(this.getGeneralPage()); + + pages.push({ + title: 'Annotations', + id: 'annotations', + icon: 'comment-alt', + render: () => , + }); + + pages.push({ + title: 'Variables', + id: 'templating', + icon: 'calculator-alt', + render: () => , + }); + + pages.push({ + title: 'Links', + id: 'links', + icon: 'link', + render: () => , + }); + } + + if (dashboard.meta.canMakeEditable) { + pages.push({ + title: 'General', + icon: 'sliders-v-alt', + id: 'settings', + render: () => this.renderMakeEditable(), + }); + } + + if (dashboard.id && dashboard.meta.canSave) { + pages.push({ + title: 'Versions', + id: 'versions', + icon: 'history', + render: () => , + }); + } + + if (dashboard.id && dashboard.meta.canAdmin) { + pages.push({ + title: 'Permissions', + id: 'permissions', + icon: 'lock', + render: () => , + }); + } + + pages.push({ + title: 'JSON Model', + id: 'dashboard_json', + icon: 'arrow', + render: () => , + }); + + return pages; + } + + onMakeEditable = () => { + const { dashboard } = this.props; + dashboard.editable = true; + dashboard.meta.canMakeEditable = false; + dashboard.meta.canEdit = true; + dashboard.meta.canSave = true; + this.forceUpdate(); + }; + + onPostSave = () => { + this.props.dashboard.meta.hasUnsavedFolderChange = false; + dashboardWatcher.reloadPage(); + }; + + renderMakeEditable(): React.ReactNode { + return ( +
+
Dashboard not editable
+ +
+ ); + } + + getGeneralPage(): SettingsPage { + return { + title: 'General', + id: 'settings', + icon: 'sliders-v-alt', + render: () => , + }; + } + + render() { + const { dashboard, editview } = this.props; + const folderTitle = dashboard.meta.folderTitle; + const pages = this.getPages(); + const currentPage = pages.find((page) => page.id === editview) ?? pages[0]; + const canSaveAs = contextSrv.hasEditPermissionInFolders; + const canSave = dashboard.meta.canSave; + const styles = getStyles(config.theme2); + + return ( +
+ + +
+
+ +
{currentPage.render()}
+
+
+
+
+ ); + } +} + +const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ + scrollInner: css` + min-width: 100%; + min-height: 100%; + display: flex; + `, + settingsWrapper: css` + margin: ${theme.spacing(2)}; + display: flex; + flex-grow: 1; + `, + settingsContent: css` + flex-grow: 1; + height: 100%; + padding: 32px; + border: 1px solid ${theme.colors.border.weak}; + background: ${theme.colors.background.primary}; + border-radius: ${theme.shape.borderRadius()}; + `, +})); diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx new file mode 100644 index 0000000..e17ee1d --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Icon, HorizontalGroup } from '@grafana/ui'; + +type Props = { + title: string; + onGoBack: () => void; + isEditing: boolean; +}; + +export const DashboardSettingsHeader: React.FC = ({ onGoBack, isEditing, title }) => { + return ( +
+ +

+ + {title} + + {isEditing && ( + + Edit + + )} +

+
+
+ ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx new file mode 100644 index 0000000..cc92988 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { TimeZone } from '@grafana/data'; +import { TagsInput, Input, Field, CollapsableSection, RadioButtonGroup } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { DashboardModel } from '../../state/DashboardModel'; +import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton'; +import { TimePickerSettings } from './TimePickerSettings'; + +interface Props { + dashboard: DashboardModel; +} + +const GRAPH_TOOLTIP_OPTIONS = [ + { value: 0, label: 'Default' }, + { value: 1, label: 'Shared crosshair' }, + { value: 2, label: 'Shared Tooltip' }, +]; + +export const GeneralSettings: React.FC = ({ dashboard }) => { + const [renderCounter, setRenderCounter] = useState(0); + + const onFolderChange = (folder: { id: number; title: string }) => { + dashboard.meta.folderId = folder.id; + dashboard.meta.folderTitle = folder.title; + dashboard.meta.hasUnsavedFolderChange = true; + }; + + const onBlur = (event: React.FocusEvent) => { + dashboard[event.currentTarget.name as 'title' | 'description'] = event.currentTarget.value; + }; + + const onTooltipChange = (graphTooltip: number) => { + dashboard.graphTooltip = graphTooltip; + setRenderCounter(renderCounter + 1); + }; + + const onRefreshIntervalChange = (intervals: string[]) => { + dashboard.timepicker.refresh_intervals = intervals.filter((i) => i.trim() !== ''); + }; + + const onNowDelayChange = (nowDelay: string) => { + dashboard.timepicker.nowDelay = nowDelay; + }; + + const onHideTimePickerChange = (hide: boolean) => { + dashboard.timepicker.hidden = hide; + setRenderCounter(renderCounter + 1); + }; + + const onTimeZoneChange = (timeZone: TimeZone) => { + dashboard.timezone = timeZone; + setRenderCounter(renderCounter + 1); + }; + + const onTagsChange = (tags: string[]) => { + dashboard.tags = tags; + setRenderCounter(renderCounter + 1); + }; + + const onEditableChange = (value: boolean) => { + dashboard.editable = value; + setRenderCounter(renderCounter + 1); + }; + + const editableOptions = [ + { label: 'Editable', value: true }, + { label: 'Read-only', value: false }, + ]; + + return ( +
+

+ General +

+
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+ {dashboard.meta.canSave && } +
+
+ ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx new file mode 100644 index 0000000..fd12197 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { Button, CodeEditor, HorizontalGroup, useStyles2 } from '@grafana/ui'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { getDashboardSrv } from '../../services/DashboardSrv'; +import { DashboardModel } from '../../state/DashboardModel'; +import { GrafanaTheme2 } from '@grafana/data'; + +interface Props { + dashboard: DashboardModel; +} + +export const JsonEditorSettings: React.FC = ({ dashboard }) => { + const [dashboardJson, setDashboardJson] = useState(JSON.stringify(dashboard.getSaveModelClone(), null, 2)); + const onBlur = (value: string) => { + setDashboardJson(value); + }; + const onClick = () => { + getDashboardSrv() + .saveJSONDashboard(dashboardJson) + .then(() => { + dashboardWatcher.reloadPage(); + }); + }; + const styles = useStyles2(getStyles); + + return ( +
+

JSON Model

+
+ The JSON model below is the data structure that defines the dashboard. This includes dashboard settings, panel + settings, layout, queries, and so on. +
+ +
+ + {({ width, height }) => ( + + )} + +
+ {dashboard.meta.canSave && ( + + + + )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + editWrapper: css` + height: calc(100vh - 250px); + margin-bottom: 10px; + `, +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx new file mode 100644 index 0000000..00e1bfe --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { selectors } from '@grafana/e2e-selectors'; + +import { LinksSettings } from './LinksSettings'; + +describe('LinksSettings', () => { + let dashboard = {}; + const links = [ + { + asDropdown: false, + icon: 'external link', + includeVars: false, + keepTime: false, + tags: [], + targetBlank: false, + title: 'link 1', + tooltip: '', + type: 'link', + url: 'https://www.google.com', + }, + { + asDropdown: false, + icon: 'external link', + includeVars: false, + keepTime: false, + tags: ['gdev'], + targetBlank: false, + title: 'link 2', + tooltip: '', + type: 'dashboards', + url: '', + }, + { + asDropdown: false, + icon: 'external link', + includeVars: false, + keepTime: false, + tags: [], + targetBlank: false, + title: '', + tooltip: '', + type: 'link', + url: 'https://www.bing.com', + }, + ]; + + const getTableBody = () => screen.getAllByRole('rowgroup')[1]; + const getTableBodyRows = () => within(getTableBody()).getAllByRole('row'); + const assertRowHasText = (index: number, text: string) => { + expect(within(getTableBodyRows()[index]).queryByText(text)).toBeInTheDocument(); + }; + + beforeEach(() => { + dashboard = { + id: 74, + version: 7, + links: [...links], + }; + }); + + test('it renders a header and cta if no links', () => { + const linklessDashboard = { ...dashboard, links: [] }; + // @ts-ignore + render(); + + expect(screen.getByRole('heading', { name: 'Dashboard links' })).toBeInTheDocument(); + expect( + screen.getByLabelText(selectors.components.CallToActionCard.button('Add dashboard link')) + ).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + test('it renders a table of links', () => { + // @ts-ignore + render(); + + expect(getTableBodyRows().length).toBe(links.length); + expect( + screen.queryByLabelText(selectors.components.CallToActionCard.button('Add dashboard link')) + ).not.toBeInTheDocument(); + }); + + test('it rearranges the order of dashboard links', () => { + // @ts-ignore + render(); + + // Check that we have sorting buttons + expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument(); + expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument(); + + expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument(); + expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument(); + + expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument(); + expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-down' })).not.toBeInTheDocument(); + + // Checking the original order + assertRowHasText(0, links[0].title); + assertRowHasText(1, links[1].title); + assertRowHasText(2, links[2].url); + + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[0]); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[1]); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-up' })[0]); + + // Checking if it has changed the sorting accordingly + assertRowHasText(0, links[2].url); + assertRowHasText(1, links[1].title); + assertRowHasText(2, links[0].title); + }); + + test('it duplicates dashboard links', () => { + // @ts-ignore + render(); + + expect(getTableBodyRows().length).toBe(links.length); + + userEvent.click(within(getTableBody()).getAllByRole('button', { name: /copy/i })[0]); + + expect(getTableBodyRows().length).toBe(links.length + 1); + expect(within(getTableBody()).getAllByText(links[0].title).length).toBe(2); + }); + + test('it deletes dashboard links', () => { + // @ts-ignore + render(); + + expect(getTableBodyRows().length).toBe(links.length); + + userEvent.click(within(getTableBody()).getAllByRole('button', { name: /delete/i })[0]); + + expect(getTableBodyRows().length).toBe(links.length - 1); + expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument(); + }); + + test('it renders a form which modifies dashboard links', () => { + // @ts-ignore + render(); + userEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(screen.queryByText('Type')).toBeInTheDocument(); + expect(screen.queryByText('Title')).toBeInTheDocument(); + expect(screen.queryByText('With tags')).toBeInTheDocument(); + + expect(screen.queryByText('Url')).not.toBeInTheDocument(); + expect(screen.queryByText('Tooltip')).not.toBeInTheDocument(); + expect(screen.queryByText('Icon')).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Dashboards')); + expect(screen.queryAllByText('Dashboards')).toHaveLength(2); + expect(screen.queryByText('Link')).toBeVisible(); + + userEvent.click(screen.getByText('Link')); + + expect(screen.queryByText('URL')).toBeInTheDocument(); + expect(screen.queryByText('Tooltip')).toBeInTheDocument(); + expect(screen.queryByText('Icon')).toBeInTheDocument(); + + userEvent.clear(screen.getByRole('textbox', { name: /title/i })); + userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link'); + userEvent.click( + within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i) + ); + + expect(getTableBodyRows().length).toBe(links.length + 1); + expect(within(getTableBody()).queryByText('New Dashboard Link')).toBeInTheDocument(); + + userEvent.click(screen.getAllByText(links[0].type)[0]); + userEvent.clear(screen.getByRole('textbox', { name: /title/i })); + userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link'); + userEvent.click( + within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i) + ); + + expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument(); + expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx new file mode 100644 index 0000000..4b3eeba --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import { DashboardModel } from '../../state/DashboardModel'; +import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings'; +import { newLink } from '../LinksSettings/LinkSettingsEdit'; +import { DashboardSettingsHeader } from './DashboardSettingsHeader'; +interface Props { + dashboard: DashboardModel; +} + +export type LinkSettingsMode = 'list' | 'new' | 'edit'; + +export const LinksSettings: React.FC = ({ dashboard }) => { + const [editIdx, setEditIdx] = useState(null); + + const onGoBack = () => { + setEditIdx(null); + }; + + const onNew = () => { + dashboard.links = [...dashboard.links, { ...newLink }]; + setEditIdx(dashboard.links.length - 1); + }; + + const onEdit = (idx: number) => { + setEditIdx(idx); + }; + + const isEditing = editIdx !== null; + + return ( + <> + + {!isEditing && } + {isEditing && } + + ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx b/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx new file mode 100644 index 0000000..be40e1d --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx @@ -0,0 +1,23 @@ +import React, { ButtonHTMLAttributes } from 'react'; +import { Button, useStyles } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export interface Props extends ButtonHTMLAttributes {} + +export const ListNewButton: React.FC = ({ children, ...restProps }) => { + const styles = useStyles(getStyles); + return ( +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + buttonWrapper: css` + padding: ${theme.spacing.lg} 0; + `, +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx new file mode 100644 index 0000000..f32a7fa --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx @@ -0,0 +1,85 @@ +import React, { PureComponent } from 'react'; +import { Input, TimeZonePicker, Field, Switch, CollapsableSection } from '@grafana/ui'; +import { rangeUtil, TimeZone } from '@grafana/data'; +import { isEmpty } from 'lodash'; +import { selectors } from '@grafana/e2e-selectors'; +import { AutoRefreshIntervals } from './AutoRefreshIntervals'; + +interface Props { + onTimeZoneChange: (timeZone: TimeZone) => void; + onRefreshIntervalChange: (interval: string[]) => void; + onNowDelayChange: (nowDelay: string) => void; + onHideTimePickerChange: (hide: boolean) => void; + refreshIntervals: string[]; + timePickerHidden: boolean; + nowDelay: string; + timezone: TimeZone; +} + +interface State { + isNowDelayValid: boolean; +} + +export class TimePickerSettings extends PureComponent { + state: State = { isNowDelayValid: true }; + + onNowDelayChange = (event: React.FormEvent) => { + const value = event.currentTarget.value; + + if (isEmpty(value)) { + this.setState({ isNowDelayValid: true }); + return this.props.onNowDelayChange(value); + } + + if (rangeUtil.isValidTimeSpan(value)) { + this.setState({ isNowDelayValid: true }); + return this.props.onNowDelayChange(value); + } + + this.setState({ isNowDelayValid: false }); + }; + + onHideTimePickerChange = () => { + this.props.onHideTimePickerChange(!this.props.timePickerHidden); + }; + + onTimeZoneChange = (timeZone: string) => { + if (typeof timeZone !== 'string') { + return; + } + this.props.onTimeZoneChange(timeZone); + }; + + render() { + return ( + + + + + + + + + + + + + ); + } +} diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx new file mode 100644 index 0000000..00788bb --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { historySrv } from '../VersionHistory/HistorySrv'; +import { VersionsSettings, VERSIONS_FETCH_LIMIT } from './VersionsSettings'; +import { versions, diffs } from './__mocks__/versions'; + +jest.mock('../VersionHistory/HistorySrv'); + +const queryByFullText = (text: string) => + screen.queryByText((_, node: Element | undefined | null) => { + if (node) { + const nodeHasText = (node: HTMLElement | Element) => node.textContent?.includes(text); + const currentNodeHasText = nodeHasText(node); + const childrenDontHaveText = Array.from(node.children).every((child) => !nodeHasText(child)); + return Boolean(currentNodeHasText && childrenDontHaveText); + } + return false; + }); + +describe('VersionSettings', () => { + const dashboard: any = { + id: 74, + version: 11, + formatDate: jest.fn(() => 'date'), + getRelativeTime: jest.fn(() => 'time ago'), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('renders a header and a loading indicator followed by results in a table', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions); + render(); + + expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument(); + expect(screen.queryByText(/fetching history list/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); + + expect(tableBodyRows.length).toBe(versions.length); + + const firstRow = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row')[0]; + + expect(within(firstRow).getByText(/latest/i)).toBeInTheDocument(); + expect(within(screen.getByRole('table')).getAllByText(/latest/i)).toHaveLength(1); + }); + + test('does not render buttons if versions === 1', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1)); + render(); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + }); + + test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5)); + render(); + + expect(screen.queryByRole('button', { name: /show more versions|/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).toBeInTheDocument(); + }); + + test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); + render(); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const compareButton = screen.getByRole('button', { name: /compare versions/i }); + const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); + + expect(showMoreButton).toBeInTheDocument(); + expect(showMoreButton).toBeEnabled(); + + expect(compareButton).toBeInTheDocument(); + expect(compareButton).toBeDisabled(); + }); + + test('clicking show more appends results to the table', async () => { + historySrv.getHistoryList + // @ts-ignore + .mockImplementationOnce(() => Promise.resolve(versions.slice(0, VERSIONS_FETCH_LIMIT))) + .mockImplementationOnce(() => Promise.resolve(versions.slice(VERSIONS_FETCH_LIMIT, versions.length))); + + render(); + + expect(historySrv.getHistoryList).toBeCalledTimes(1); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(VERSIONS_FETCH_LIMIT); + + const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); + userEvent.click(showMoreButton); + + expect(historySrv.getHistoryList).toBeCalledTimes(2); + expect(screen.queryByText(/Fetching more entries/i)).toBeInTheDocument(); + + await waitFor(() => + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.length) + ); + }); + + test('selecting two versions and clicking compare button should render compare view', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); + historySrv.getDashboardVersion + // @ts-ignore + .mockImplementationOnce(() => Promise.resolve(diffs.lhs)) + .mockImplementationOnce(() => Promise.resolve(diffs.rhs)); + + render(); + + expect(historySrv.getHistoryList).toBeCalledTimes(1); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + const compareButton = screen.getByRole('button', { name: /compare versions/i }); + const tableBody = screen.getAllByRole('rowgroup')[1]; + userEvent.click(within(tableBody).getAllByRole('checkbox')[0]); + userEvent.click(within(tableBody).getAllByRole('checkbox')[VERSIONS_FETCH_LIMIT - 1]); + + expect(compareButton).toBeEnabled(); + + userEvent.click(within(tableBody).getAllByRole('checkbox')[1]); + + expect(compareButton).toBeDisabled(); + + userEvent.click(within(tableBody).getAllByRole('checkbox')[1]); + userEvent.click(compareButton); + + await waitFor(() => expect(screen.getByRole('heading', { name: /versions comparing 2 11/i })).toBeInTheDocument()); + + expect(queryByFullText('Version 11 updated by admin')).toBeInTheDocument(); + expect(queryByFullText('Version 2 updated by admin')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /restore to version 2/i })).toBeInTheDocument(); + expect(screen.queryAllByTestId('diffGroup').length).toBe(5); + + const diffGroups = screen.getAllByTestId('diffGroup'); + + expect(queryByFullText('description added The dashboard description')).toBeInTheDocument(); + expect(queryByFullText('panels changed')).toBeInTheDocument(); + expect(within(diffGroups[1]).queryByRole('list')).toBeInTheDocument(); + expect(within(diffGroups[1]).queryByText(/added title/i)).toBeInTheDocument(); + expect(within(diffGroups[1]).queryByText(/changed id/i)).toBeInTheDocument(); + expect(queryByFullText('tags deleted item 0')).toBeInTheDocument(); + expect(queryByFullText('timepicker added 1 refresh_intervals')).toBeInTheDocument(); + expect(queryByFullText('version changed')).toBeInTheDocument(); + expect(screen.queryByText(/view json diff/i)).toBeInTheDocument(); + + userEvent.click(screen.getByText(/view json diff/i)); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx new file mode 100644 index 0000000..f5702c2 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -0,0 +1,192 @@ +import React, { PureComponent } from 'react'; +import { Spinner, HorizontalGroup } from '@grafana/ui'; +import { DashboardModel } from '../../state/DashboardModel'; +import { + historySrv, + RevisionsModel, + VersionHistoryTable, + VersionHistoryHeader, + VersionsHistoryButtons, + VersionHistoryComparison, +} from '../VersionHistory'; + +interface Props { + dashboard: DashboardModel; +} + +type State = { + isLoading: boolean; + isAppending: boolean; + versions: DecoratedRevisionModel[]; + viewMode: 'list' | 'compare'; + diffData: { lhs: any; rhs: any }; + newInfo?: DecoratedRevisionModel; + baseInfo?: DecoratedRevisionModel; + isNewLatest: boolean; +}; + +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; +}; + +export const VERSIONS_FETCH_LIMIT = 10; + +export class VersionsSettings extends PureComponent { + limit: number; + start: number; + + constructor(props: Props) { + super(props); + this.limit = VERSIONS_FETCH_LIMIT; + this.start = 0; + this.state = { + isAppending: true, + isLoading: true, + versions: [], + viewMode: 'list', + isNewLatest: false, + diffData: { + lhs: {}, + rhs: {}, + }, + }; + } + + componentDidMount() { + this.getVersions(); + } + + getVersions = (append = false) => { + this.setState({ isAppending: append }); + historySrv + .getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start }) + .then((res) => { + this.setState({ + isLoading: false, + versions: [...this.state.versions, ...this.decorateVersions(res)], + }); + this.start += this.limit; + }) + .catch((err) => console.log(err)) + .finally(() => this.setState({ isAppending: false })); + }; + + getDiff = async () => { + const selectedVersions = this.state.versions.filter((version) => version.checked); + const [newInfo, baseInfo] = selectedVersions; + const isNewLatest = newInfo.version === this.props.dashboard.version; + + this.setState({ + isLoading: true, + }); + + const lhs = await historySrv.getDashboardVersion(this.props.dashboard.id, baseInfo.version); + const rhs = await historySrv.getDashboardVersion(this.props.dashboard.id, newInfo.version); + + this.setState({ + baseInfo, + isLoading: false, + isNewLatest, + newInfo, + viewMode: 'compare', + diffData: { + lhs: lhs.data, + rhs: rhs.data, + }, + }); + }; + + decorateVersions = (versions: RevisionsModel[]) => + versions.map((version) => ({ + ...version, + createdDateString: this.props.dashboard.formatDate(version.created), + ageString: this.props.dashboard.getRelativeTime(version.created), + checked: false, + })); + + isLastPage() { + return this.state.versions.find((rev) => rev.version === 1); + } + + onCheck = (ev: React.FormEvent, versionId: number) => { + this.setState({ + versions: this.state.versions.map((version) => + version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version + ), + }); + }; + + reset = () => { + this.setState({ + baseInfo: undefined, + diffData: { + lhs: {}, + rhs: {}, + }, + isNewLatest: false, + newInfo: undefined, + versions: this.state.versions.map((version) => ({ ...version, checked: false })), + viewMode: 'list', + }); + }; + + render() { + const { versions, viewMode, baseInfo, newInfo, isNewLatest, isLoading, diffData } = this.state; + const canCompare = versions.filter((version) => version.checked).length !== 2; + const showButtons = versions.length > 1; + const hasMore = versions.length >= this.limit; + + if (viewMode === 'compare') { + return ( +
+ + {isLoading ? ( + + ) : ( + + )} +
+ ); + } + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + {this.state.isAppending && } + {showButtons && ( + + )} +
+ ); + } +} + +const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( + + + {msg} + +); diff --git a/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts new file mode 100644 index 0000000..fc3e141 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts @@ -0,0 +1,205 @@ +export const versions = [ + { + id: 249, + dashboardId: 74, + parentVersion: 10, + restoredFrom: 0, + version: 11, + created: '2021-01-15T14:44:44+01:00', + createdBy: 'admin', + message: 'testing changes...', + }, + { + id: 247, + dashboardId: 74, + parentVersion: 9, + restoredFrom: 0, + version: 10, + created: '2021-01-15T10:19:17+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 246, + dashboardId: 74, + parentVersion: 8, + restoredFrom: 0, + version: 9, + created: '2021-01-15T10:18:12+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 245, + dashboardId: 74, + parentVersion: 7, + restoredFrom: 0, + version: 8, + created: '2021-01-15T10:11:16+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 239, + dashboardId: 74, + parentVersion: 6, + restoredFrom: 0, + version: 7, + created: '2021-01-14T15:14:25+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 237, + dashboardId: 74, + parentVersion: 5, + restoredFrom: 0, + version: 6, + created: '2021-01-14T14:55:29+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 236, + dashboardId: 74, + parentVersion: 4, + restoredFrom: 0, + version: 5, + created: '2021-01-14T14:28:01+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 218, + dashboardId: 74, + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2021-01-08T10:45:33+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 217, + dashboardId: 74, + parentVersion: 2, + restoredFrom: 0, + version: 3, + created: '2021-01-05T15:41:33+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 216, + dashboardId: 74, + parentVersion: 1, + restoredFrom: 0, + version: 2, + created: '2021-01-05T15:01:50+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 215, + dashboardId: 74, + parentVersion: 1, + restoredFrom: 0, + version: 1, + created: '2021-01-05T14:59:15+01:00', + createdBy: 'admin', + message: '', + }, +]; + +export const diffs = { + lhs: { + data: { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + editable: true, + gnetId: null, + graphTooltip: 0, + id: 141, + links: [], + panels: [ + { + type: 'graph', + id: 4, + }, + ], + schemaVersion: 27, + style: 'dark', + tags: ['the tag'], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'test dashboard', + uid: '_U4zObQMz', + version: 2, + }, + }, + rhs: { + data: { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + description: 'The dashboard description', + editable: true, + gnetId: null, + graphTooltip: 0, + id: 141, + links: [], + panels: [ + { + type: 'graph', + title: 'panel title', + id: 6, + }, + ], + schemaVersion: 27, + style: 'dark', + tags: [], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: { + refresh_intervals: ['5s'], + }, + timezone: '', + title: 'test dashboard', + uid: '_U4zObQMz', + version: 11, + }, + }, +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts new file mode 100644 index 0000000..92fde59 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -0,0 +1 @@ +export { DashboardSettings } from './DashboardSettings'; diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx new file mode 100644 index 0000000..a32207f --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { DeleteDashboardModal } from './DeleteDashboardModal'; +import { Button, ModalsController } from '@grafana/ui'; +import { DashboardModel } from '../../state'; + +type Props = { + dashboard: DashboardModel; +}; + +export const DeleteDashboardButton = ({ dashboard }: Props) => ( + + {({ showModal, hideModal }) => ( + + )} + +); diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx new file mode 100644 index 0000000..c66733e --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { sumBy } from 'lodash'; +import { Modal, ConfirmModal, Button } from '@grafana/ui'; +import { DashboardModel, PanelModel } from '../../state'; +import { useDashboardDelete } from './useDashboardDelete'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +type DeleteDashboardModalProps = { + hideModal(): void; + dashboard: DashboardModel; +}; + +export const DeleteDashboardModal: React.FC = ({ hideModal, dashboard }) => { + const isProvisioned = dashboard.meta.provisioned; + const { onDeleteDashboard } = useDashboardDelete(dashboard.uid); + + const [, onConfirm] = useAsyncFn(async () => { + await onDeleteDashboard(); + hideModal(); + }, [hideModal]); + + const modalBody = getModalBody(dashboard.panels, dashboard.title); + + if (isProvisioned) { + return ; + } + + return ( + + ); +}; + +const getModalBody = (panels: PanelModel[], title: string) => { + const totalAlerts = sumBy(panels, (panel) => (panel.alert ? 1 : 0)); + return totalAlerts > 0 ? ( + <> +

Do you want to delete this dashboard?

+

+ This dashboard contains {totalAlerts} alert{totalAlerts > 1 ? 's' : ''}. Deleting this dashboard also deletes + deletes those alerts +

+ + ) : ( + <> +

Do you want to delete this dashboard?

+

{title}

+ + ); +}; + +const ProvisionedDeleteModal = ({ hideModal, provisionedId }: { hideModal(): void; provisionedId: string }) => ( + +

+ This dashboard is managed by Grafana provisioning and cannot be deleted. Remove the dashboard from the config file + to delete it. +

+

+ + See{' '} + + documentation + {' '} + for more information about provisioning. + +
+ File path: {provisionedId} +

+ + + +
+); diff --git a/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx new file mode 100644 index 0000000..9d1526b --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { useAsyncFn } from 'react-use'; +import { AppEvents } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { deleteDashboard } from 'app/features/manage-dashboards/state/actions'; +import { locationService } from '@grafana/runtime'; + +export const useDashboardDelete = (uid: string) => { + const [state, onDeleteDashboard] = useAsyncFn(() => deleteDashboard(uid, false), []); + + useEffect(() => { + if (state.value) { + locationService.replace('/'); + appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', state.value.title + ' has been deleted']); + } + }, [state]); + + return { state, onDeleteDashboard }; +}; diff --git a/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts new file mode 100644 index 0000000..e389c9a --- /dev/null +++ b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts @@ -0,0 +1,205 @@ +import { map, find } from 'lodash'; +import { IScope } from 'angular'; +import { AppEvents } from '@grafana/data'; + +import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { ValidationSrv } from 'app/features/manage-dashboards'; +import { ContextSrv } from 'app/core/services/context_srv'; +import { promiseToDigest } from '../../../../core/utils/promiseToDigest'; +import { createFolder } from 'app/features/manage-dashboards/state/actions'; + +export class FolderPickerCtrl { + declare initialTitle: string; + initialFolderId?: number; + labelClass: string; + onChange: any; + onLoad: any; + onCreateFolder: any; + enterFolderCreation: any; + exitFolderCreation: any; + declare enableCreateNew: boolean; + declare enableReset: boolean; + rootName = 'General'; + folder: any; + createNewFolder?: boolean; + newFolderName?: string; + newFolderNameTouched?: boolean; + hasValidationError?: boolean; + validationError: any; + isEditor: boolean; + dashboardId?: number; + + /** @ngInject */ + constructor(private validationSrv: ValidationSrv, private contextSrv: ContextSrv, private $scope: IScope) { + this.isEditor = this.contextSrv.isEditor; + + if (!this.labelClass) { + this.labelClass = 'width-7'; + } + + this.loadInitialValue(); + } + + getOptions(query: string) { + const params = { + query, + type: 'dash-folder', + permission: 'Edit', + }; + + return promiseToDigest(this.$scope)( + backendSrv.get('api/search', params).then((result: any) => { + if ( + this.isEditor && + (query === '' || + query.toLowerCase() === 'g' || + query.toLowerCase() === 'ge' || + query.toLowerCase() === 'gen' || + query.toLowerCase() === 'gene' || + query.toLowerCase() === 'gener' || + query.toLowerCase() === 'genera' || + query.toLowerCase() === 'general') + ) { + result.unshift({ title: this.rootName, id: 0 }); + } + + if (this.isEditor && this.enableCreateNew && query === '') { + result.unshift({ title: '-- New folder --', id: -1 }); + } + + if (this.enableReset && query === '' && this.initialTitle !== '') { + result.unshift({ title: this.initialTitle, id: null }); + } + + return map(result, (item) => { + return { text: item.title, value: item.id }; + }); + }) + ); + } + + onFolderChange(option: { value: number; text: string }) { + if (!option) { + option = { value: 0, text: this.rootName }; + } else if (option.value === -1) { + this.createNewFolder = true; + this.enterFolderCreation(); + return; + } + this.onChange({ $folder: { id: option.value, title: option.text } }); + } + + newFolderNameChanged() { + this.newFolderNameTouched = true; + + this.validationSrv + .validateNewFolderName(this.newFolderName) + .then(() => { + this.hasValidationError = false; + }) + .catch((err: any) => { + this.hasValidationError = true; + this.validationError = err.message; + }); + } + + createFolder(evt: any) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + return promiseToDigest(this.$scope)( + createFolder({ title: this.newFolderName }).then((result: { title: string; id: number }) => { + appEvents.emit(AppEvents.alertSuccess, ['Folder created', 'OK']); + + this.closeCreateFolder(); + this.folder = { text: result.title, value: result.id }; + this.onFolderChange(this.folder); + }) + ); + } + + cancelCreateFolder(evt: any) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + this.closeCreateFolder(); + this.loadInitialValue(); + } + + private closeCreateFolder() { + this.exitFolderCreation(); + this.createNewFolder = false; + this.hasValidationError = false; + this.validationError = null; + this.newFolderName = ''; + this.newFolderNameTouched = false; + } + + private loadInitialValue() { + const resetFolder: { text: string; value: any } = { text: this.initialTitle, value: null }; + const rootFolder: { text: string; value: any } = { text: this.rootName, value: 0 }; + + this.getOptions('').then((result: any[]) => { + let folder: { text: string; value: any } | undefined; + + if (this.initialFolderId) { + // @ts-ignore + folder = find(result, { value: this.initialFolderId }); + } else if (this.enableReset && this.initialTitle && this.initialFolderId === null) { + folder = resetFolder; + } + + if (!folder) { + if (this.isEditor) { + folder = rootFolder; + } else { + // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard + const isPersistedDashBoard = this.dashboardId ? true : false; + if (isPersistedDashBoard) { + folder = resetFolder; + } else { + folder = result.length > 0 ? result[0] : resetFolder; + } + } + } + + this.folder = folder; + + // if this is not the same as our initial value notify parent + if (this.folder.value !== this.initialFolderId) { + this.onChange({ $folder: { id: this.folder.value, title: this.folder.text } }); + } + }); + } +} + +export function folderPicker() { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html', + controller: FolderPickerCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + initialTitle: '<', + initialFolderId: '<', + labelClass: '@', + rootName: '@', + onChange: '&', + onCreateFolder: '&', + enterFolderCreation: '&', + exitFolderCreation: '&', + enableCreateNew: '@', + enableReset: '@', + dashboardId: '; + // The last raw response + data?: PanelData; + isDataLoading: boolean; + dataOptions: GetDataOptions; + // If the datasource supports custom metadata + metadataDatasource?: DataSourceApi; + onDataOptionsChange: (options: GetDataOptions) => void; + onClose: () => void; +} + +export const InspectContent: React.FC = ({ + panel, + plugin, + dashboard, + tabs, + data, + isDataLoading, + dataOptions, + metadataDatasource, + defaultTab, + onDataOptionsChange, + onClose, +}) => { + const [currentTab, setCurrentTab] = useState(defaultTab ?? InspectTab.Data); + + if (!plugin) { + return null; + } + + const styles = getPanelInspectorStyles(); + const error = data?.error; + + // Validate that the active tab is actually valid and allowed + let activeTab = currentTab; + if (!tabs.find((item) => item.value === currentTab)) { + activeTab = InspectTab.JSON; + } + const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text'); + + return ( + setCurrentTab(item.value || InspectTab.Data)} + /> + } + width="50%" + onClose={onClose} + expandable + > + {activeTab === InspectTab.Data && ( + + )} + + + {data && activeTab === InspectTab.Meta && ( + + )} + + {activeTab === InspectTab.JSON && ( + + )} + {activeTab === InspectTab.Error && } + {data && activeTab === InspectTab.Stats && } + {data && activeTab === InspectTab.Query && ( + panel.refresh()} /> + )} + + + + ); +}; diff --git a/public/app/features/dashboard/components/Inspector/PanelInspector.tsx b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx new file mode 100644 index 0000000..90e3c79 --- /dev/null +++ b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import { connect, MapStateToProps } from 'react-redux'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { PanelPlugin } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { StoreState } from 'app/types'; +import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; +import { usePanelLatestData } from '../PanelEditor/usePanelLatestData'; +import { InspectContent } from './InspectContent'; +import { useDatasourceMetadata, useInspectTabs } from './hooks'; +import { useLocation } from 'react-router-dom'; +import { InspectTab } from 'app/features/inspector/types'; + +interface OwnProps { + dashboard: DashboardModel; + panel: PanelModel; +} + +export interface ConnectedProps { + plugin?: PanelPlugin | null; +} + +export type Props = OwnProps & ConnectedProps; + +const PanelInspectorUnconnected: React.FC = ({ panel, dashboard, plugin }) => { + const [dataOptions, setDataOptions] = useState({ + withTransforms: false, + withFieldConfig: true, + }); + + const location = useLocation(); + const { data, isLoading, error } = usePanelLatestData(panel, dataOptions, true); + const metaDs = useDatasourceMetadata(data); + const tabs = useInspectTabs(dashboard, plugin, error, metaDs); + const defaultTab = new URLSearchParams(location.search).get('inspectTab') as InspectTab; + + const onClose = () => { + locationService.partial({ + inspect: null, + inspectTab: null, + }); + }; + + if (!plugin) { + return null; + } + + return ( + + ); +}; + +const mapStateToProps: MapStateToProps = (state, props) => { + const panelState = state.dashboard.panels[props.panel.id]; + if (!panelState) { + return { plugin: null }; + } + + return { + plugin: panelState.plugin, + }; +}; + +export const PanelInspector = connect(mapStateToProps)(PanelInspectorUnconnected); diff --git a/public/app/features/dashboard/components/Inspector/hooks.ts b/public/app/features/dashboard/components/Inspector/hooks.ts new file mode 100644 index 0000000..a3aca48 --- /dev/null +++ b/public/app/features/dashboard/components/Inspector/hooks.ts @@ -0,0 +1,64 @@ +import { DataQueryError, DataSourceApi, PanelData, PanelPlugin } from '@grafana/data'; +import useAsync from 'react-use/lib/useAsync'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { useMemo } from 'react'; +import { supportsDataQuery } from '../PanelEditor/utils'; +import { InspectTab } from 'app/features/inspector/types'; + +/** + * Given PanelData return first data source supporting metadata inspector + */ +export const useDatasourceMetadata = (data?: PanelData) => { + const state = useAsync(async () => { + const targets = data?.request?.targets || []; + + if (data && data.series && targets.length) { + for (const frame of data.series) { + if (frame.meta && frame.meta.custom) { + // get data source from first query + const dataSource = await getDataSourceSrv().get(targets[0].datasource); + if (dataSource && dataSource.components?.MetadataInspector) { + return dataSource; + } + } + } + } + + return undefined; + }, [data]); + return state.value; +}; + +/** + * Configures tabs for PanelInspector + */ +export const useInspectTabs = ( + dashboard: DashboardModel, + plugin: PanelPlugin | undefined | null, + error?: DataQueryError, + metaDs?: DataSourceApi +) => { + return useMemo(() => { + const tabs = []; + if (supportsDataQuery(plugin)) { + tabs.push({ label: 'Data', value: InspectTab.Data }); + tabs.push({ label: 'Stats', value: InspectTab.Stats }); + } + + if (metaDs) { + tabs.push({ label: 'Meta Data', value: InspectTab.Meta }); + } + + tabs.push({ label: 'JSON', value: InspectTab.JSON }); + + if (error && error.message) { + tabs.push({ label: 'Error', value: InspectTab.Error }); + } + + if (dashboard.meta.canEdit && supportsDataQuery(plugin)) { + tabs.push({ label: 'Query', value: InspectTab.Query }); + } + return tabs; + }, [plugin, metaDs, dashboard, error]); +}; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx new file mode 100644 index 0000000..367b602 --- /dev/null +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { DashboardLink, DashboardModel } from '../../state/DashboardModel'; + +export const newLink = { + icon: 'external link', + title: 'New link', + tooltip: '', + type: 'dashboards', + url: '', + asDropdown: false, + tags: [], + targetBlank: false, + keepTime: false, + includeVars: false, +} as DashboardLink; + +const linkTypeOptions = [ + { value: 'dashboards', label: 'Dashboards' }, + { value: 'link', label: 'Link' }, +]; + +export const linkIconMap: { [key: string]: string } = { + 'external link': 'external-link-alt', + dashboard: 'apps', + question: 'question-circle', + info: 'info-circle', + bolt: 'bolt', + doc: 'file-alt', + cloud: 'cloud', +}; + +const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key })); + +type LinkSettingsEditProps = { + editLinkIdx: number; + dashboard: DashboardModel; + onGoBack: () => void; +}; + +export const LinkSettingsEdit: React.FC = ({ editLinkIdx, dashboard }) => { + const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink); + + const onUpdate = (link: DashboardLink) => { + const links = [...dashboard.links]; + links.splice(editLinkIdx, 1, link); + dashboard.links = links; + setLinkSettings(link); + }; + + const onTagsChange = (tags: any[]) => { + onUpdate({ ...linkSettings, tags: tags }); + }; + + const onTypeChange = (selectedItem: SelectableValue) => { + const update = { ...linkSettings, type: selectedItem.value }; + + // clear props that are no longe revant for this type + if (update.type === 'dashboards') { + update.url = ''; + update.tooltip = ''; + } else { + update.tags = []; + } + + onUpdate(update); + }; + + const onIconChange = (selectedItem: SelectableValue) => { + onUpdate({ ...linkSettings, icon: selectedItem.value }); + }; + + const onChange = (ev: React.FocusEvent) => { + const target = ev.currentTarget; + onUpdate({ + ...linkSettings, + [target.name]: target.type === 'checkbox' ? target.checked : target.value, + }); + }; + + const isNew = linkSettings.title === newLink.title; + + return ( +
+ + + + + + + + + + + setSearchQuery(e.currentTarget.value)} + onKeyPress={onKeyPress} + prefix={} + suffix={suffix} + ref={searchRef} + placeholder="Search for..." + /> +
+ + + +
+
+ +
+ {listMode === ListMode.Visualizations && ( + {}} + /> + )} + {listMode === ListMode.LibraryPanels && ( + + )} +
+
+
+
+ ); +}; + +enum ListMode { + Visualizations, + LibraryPanels, +} + +VisualizationSelectPane.displayName = 'VisualizationSelectPane'; + +const getStyles = (theme: GrafanaTheme) => { + return { + icon: css` + color: ${theme.palette.gray33}; + `, + wrapper: css` + display: flex; + flex-direction: column; + flex: 1 1 0; + height: 100%; + `, + vizButton: css` + text-align: left; + `, + scrollWrapper: css` + flex-grow: 1; + min-height: 0; + `, + scrollContent: css` + padding: ${theme.spacing.sm}; + `, + openWrapper: css` + display: flex; + flex-direction: column; + flex: 1 1 0; + height: 100%; + background: ${theme.colors.bg1}; + border: 1px solid ${theme.colors.border1}; + `, + searchRow: css` + display: flex; + margin-bottom: ${theme.spacing.sm}; + `, + closeButton: css` + margin-left: ${theme.spacing.sm}; + `, + customFieldMargin: css` + margin-bottom: ${theme.spacing.sm}; + `, + formBox: css` + padding: ${theme.spacing.sm}; + padding-bottom: 0; + `, + }; +}; diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx new file mode 100644 index 0000000..efa8f13 --- /dev/null +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { cloneDeep } from 'lodash'; +import { + FieldConfigOptionsRegistry, + SelectableValue, + isSystemOverride as isSystemOverrideGuard, + VariableSuggestionsScope, + DynamicConfigValue, + ConfigOverrideRule, +} from '@grafana/data'; +import { Container, fieldMatchersUI, ValuePicker } from '@grafana/ui'; +import { OptionPaneRenderProps } from './types'; +import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; +import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; +import { DynamicConfigValueEditor } from './DynamicConfigValueEditor'; +import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; +import { OverrideCategoryTitle } from './OverrideCategoryTitle'; + +export function getFieldOverrideCategories(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] { + const categories: OptionsPaneCategoryDescriptor[] = []; + const currentFieldConfig = props.panel.fieldConfig; + const registry = props.plugin.fieldConfigRegistry; + const data = props.data?.series ?? []; + + if (registry.isEmpty()) { + return []; + } + + const onOverrideChange = (index: number, override: any) => { + let overrides = cloneDeep(currentFieldConfig.overrides); + overrides[index] = override; + props.onFieldConfigsChange({ ...currentFieldConfig, overrides }); + }; + + const onOverrideRemove = (overrideIndex: number) => { + let overrides = cloneDeep(currentFieldConfig.overrides); + overrides.splice(overrideIndex, 1); + props.onFieldConfigsChange({ ...currentFieldConfig, overrides }); + }; + + const onOverrideAdd = (value: SelectableValue) => { + props.onFieldConfigsChange({ + ...currentFieldConfig, + overrides: [ + ...currentFieldConfig.overrides, + { + matcher: { + id: value.value!, + }, + properties: [], + }, + ], + }); + }; + + const context = { + data, + getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope), + isOverride: true, + }; + + /** + * Main loop through all override rules + */ + for (let idx = 0; idx < currentFieldConfig.overrides.length; idx++) { + const override = currentFieldConfig.overrides[idx]; + const overrideName = `Override ${idx + 1}`; + const matcherUi = fieldMatchersUI.get(override.matcher.id); + const configPropertiesOptions = getOverrideProperties(registry); + const isSystemOverride = isSystemOverrideGuard(override); + // A way to force open new override categories + const forceOpen = override.properties.length === 0 ? 1 : 0; + + const category = new OptionsPaneCategoryDescriptor({ + title: overrideName, + id: overrideName, + forceOpen, + renderTitle: function renderOverrideTitle(isExpanded: boolean) { + return ( + onOverrideRemove(idx)} + /> + ); + }, + }); + + const onMatcherConfigChange = (options: any) => { + override.matcher.options = options; + onOverrideChange(idx, override); + }; + + const onDynamicConfigValueAdd = (o: ConfigOverrideRule, value: SelectableValue) => { + const registryItem = registry.get(value.value!); + const propertyConfig: DynamicConfigValue = { + id: registryItem.id, + value: registryItem.defaultValue, + }; + + if (override.properties) { + o.properties.push(propertyConfig); + } else { + o.properties = [propertyConfig]; + } + + onOverrideChange(idx, o); + }; + + /** + * Add override matcher UI element + */ + category.addItem( + new OptionsPaneItemDescriptor({ + title: matcherUi.name, + render: function renderMatcherUI() { + return ( + + ); + }, + }) + ); + + /** + * Loop through all override properties + */ + for (let propIdx = 0; propIdx < override.properties.length; propIdx++) { + const property = override.properties[propIdx]; + const registryItemForProperty = registry.getIfExists(property.id); + + if (!registryItemForProperty) { + continue; + } + + const onPropertyChange = (value: any) => { + override.properties[propIdx].value = value; + onOverrideChange(idx, override); + }; + + const onPropertyRemove = () => { + override.properties.splice(propIdx, 1); + onOverrideChange(idx, override); + }; + + /** + * Add override property item + */ + category.addItem( + new OptionsPaneItemDescriptor({ + title: registryItemForProperty.name, + skipField: true, + render: function renderPropertyEditor() { + return ( + + ); + }, + }) + ); + } + + /** + * Add button that adds new overrides + */ + if (!isSystemOverride && override.matcher.options) { + category.addItem( + new OptionsPaneItemDescriptor({ + title: '----------', + skipField: true, + render: function renderAddPropertyButton() { + return ( + onDynamicConfigValueAdd(override, v)} + /> + ); + }, + }) + ); + } + + categories.push(category); + } + + categories.push( + new OptionsPaneCategoryDescriptor({ + title: 'add button', + id: 'add button', + customRender: function renderAddButton() { + return ( + + !o.excludeFromPicker) + .map>((i) => ({ label: i.name, value: i.id, description: i.description }))} + onChange={(value) => onOverrideAdd(value)} + /> + + ); + }, + }) + ); + + // + // Field override rules give you fine-grained control over how your data is displayed. + // + + return categories; +} + +function getOverrideProperties(registry: FieldConfigOptionsRegistry) { + return registry + .list() + .filter((o) => !o.hideFromOverrides) + .map((item) => { + let label = item.name; + if (item.category) { + label = [...item.category, item.name].join(' > '); + } + return { + label, + value: item.id, + description: item.description, + }; + }); +} diff --git a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx new file mode 100644 index 0000000..c45a8f4 --- /dev/null +++ b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx @@ -0,0 +1,158 @@ +import { DataLinksInlineEditor, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui'; +import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; +import React from 'react'; +import { RepeatRowSelect } from '../RepeatRowSelect/RepeatRowSelect'; +import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; +import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; +import { OptionPaneRenderProps } from './types'; +import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; +import { LibraryPanelInformation } from 'app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo'; + +export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor { + const { panel, onPanelConfigChange, dashboard } = props; + const descriptor = new OptionsPaneCategoryDescriptor({ + title: 'Panel options', + id: 'Panel options', + isOpenDefault: true, + }); + + if (isPanelModelLibraryPanel(panel)) { + descriptor.addItem( + new OptionsPaneItemDescriptor({ + title: 'Library panel information', + render: function renderLibraryPanelInformation() { + return ; + }, + }) + ); + } + + return descriptor + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Title', + value: panel.title, + popularRank: 1, + render: function renderTitle() { + return ( + onPanelConfigChange('title', e.currentTarget.value)} + /> + ); + }, + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Description', + description: panel.description, + value: panel.description, + render: function renderDescription() { + return ( + + + + + Copy to clipboard + + + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx new file mode 100644 index 0000000..4feeec4 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx @@ -0,0 +1,160 @@ +import React, { PureComponent } from 'react'; +import { saveAs } from 'file-saver'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { Button, Field, Modal, Switch } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; +import { appEvents } from 'app/core/core'; +import { ShowModalReactEvent } from 'app/types/events'; +import { ViewJsonModal } from './ViewJsonModal'; +import { config } from '@grafana/runtime'; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss(): void; +} + +interface State { + shareExternally: boolean; + trimDefaults: boolean; +} + +export class ShareExport extends PureComponent { + private exporter: DashboardExporter; + + constructor(props: Props) { + super(props); + this.state = { + shareExternally: false, + trimDefaults: false, + }; + + this.exporter = new DashboardExporter(); + } + + onShareExternallyChange = () => { + this.setState({ + shareExternally: !this.state.shareExternally, + }); + }; + + onTrimDefaultsChange = () => { + this.setState({ + trimDefaults: !this.state.trimDefaults, + }); + }; + + onSaveAsFile = () => { + const { dashboard } = this.props; + const { shareExternally } = this.state; + const { trimDefaults } = this.state; + + if (shareExternally) { + this.exporter.makeExportable(dashboard).then((dashboardJson: any) => { + if (trimDefaults) { + getBackendSrv() + .post('/api/dashboards/trim', { dashboard: dashboardJson }) + .then((resp: any) => { + this.openSaveAsDialog(resp.dashboard); + }); + } else { + this.openSaveAsDialog(dashboardJson); + } + }); + } else { + if (trimDefaults) { + getBackendSrv() + .post('/api/dashboards/trim', { dashboard: dashboard.getSaveModelClone() }) + .then((resp: any) => { + this.openSaveAsDialog(resp.dashboard); + }); + } else { + this.openSaveAsDialog(dashboard.getSaveModelClone()); + } + } + }; + + onViewJson = () => { + const { dashboard } = this.props; + const { shareExternally } = this.state; + const { trimDefaults } = this.state; + + if (shareExternally) { + this.exporter.makeExportable(dashboard).then((dashboardJson: any) => { + if (trimDefaults) { + getBackendSrv() + .post('/api/dashboards/trim', { dashboard: dashboardJson }) + .then((resp: any) => { + this.openJsonModal(resp.dashboard); + }); + } else { + this.openJsonModal(dashboardJson); + } + }); + } else { + if (trimDefaults) { + getBackendSrv() + .post('/api/dashboards/trim', { dashboard: dashboard.getSaveModelClone() }) + .then((resp: any) => { + this.openJsonModal(resp.dashboard); + }); + } else { + this.openJsonModal(dashboard.getSaveModelClone()); + } + } + }; + + openSaveAsDialog = (dash: any) => { + const dashboardJsonPretty = JSON.stringify(dash, null, 2); + const blob = new Blob([dashboardJsonPretty], { + type: 'application/json;charset=utf-8', + }); + const time = new Date().getTime(); + saveAs(blob, `${dash.title}-${time}.json`); + }; + + openJsonModal = (clone: object) => { + appEvents.publish( + new ShowModalReactEvent({ + props: { + json: JSON.stringify(clone, null, 2), + }, + component: ViewJsonModal, + }) + ); + + this.props.onDismiss(); + }; + + render() { + const { onDismiss } = this.props; + const { shareExternally } = this.state; + const { trimDefaults } = this.state; + + return ( + <> +

Export this dashboard.

+ + + + {config.featureToggles.trimDefaults && ( + + + + )} + + + + + + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx b/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx new file mode 100644 index 0000000..11c77d9 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { PanelModel } from 'app/features/dashboard/state'; +import { AddLibraryPanelContents } from 'app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal'; + +interface Props { + onDismiss?: () => void; + panel?: PanelModel; + initialFolderId?: number; +} + +export const ShareLibraryPanel = ({ panel, initialFolderId, onDismiss }: Props) => { + if (!panel) { + return null; + } + + return ( + <> +

Create library panel.

+ + + ); +}; diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx new file mode 100644 index 0000000..4cf7c65 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { setTemplateSrv } from '@grafana/runtime'; +import config from 'app/core/config'; +import { ShareLink, Props, State } from './ShareLink'; +import { initTemplateSrv } from '../../../../../test/helpers/initTemplateSrv'; +import { variableAdapters } from '../../../variables/adapters'; +import { createQueryVariableAdapter } from '../../../variables/query/adapter'; + +jest.mock('app/features/dashboard/services/TimeSrv', () => ({ + getTimeSrv: () => ({ + timeRange: () => { + return { from: new Date(1000), to: new Date(2000) }; + }, + }), +})); + +function mockLocationHref(href: string) { + const location = window.location; + + let search = ''; + const searchPos = href.indexOf('?'); + if (searchPos >= 0) { + search = href.substring(searchPos); + } + + //@ts-ignore + delete window.location; + (window as any).location = { + ...location, + href, + search, + }; +} + +function setUTCTimeZone() { + (window as any).Intl.DateTimeFormat = () => { + return { + resolvedOptions: () => { + return { timeZone: 'UTC' }; + }, + }; + }; +} + +const mockUid = 'abc123'; +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + + return { + ...original, + getBackendSrv: () => ({ + post: jest.fn().mockResolvedValue({ + uid: mockUid, + url: `http://localhost:3000/goto/${mockUid}`, + }), + }), + }; +}); + +interface ScenarioContext { + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: any = { + setup: (fn: any) => { + setupFn = fn; + }, + mount: (propOverrides?: any) => { + const props: any = { + panel: undefined, + }; + + Object.assign(props, propOverrides); + ctx.wrapper = shallow(); + }, + }; + + beforeEach(() => { + setUTCTimeZone(); + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('ShareModal', () => { + let templateSrv = initTemplateSrv([]); + + beforeAll(() => { + variableAdapters.register(createQueryVariableAdapter()); + setTemplateSrv(templateSrv); + }); + + shareLinkScenario('shareUrl with current time range and panel', (ctx) => { + ctx.setup(() => { + mockLocationHref('http://server/#!/test'); + config.bootData = { + user: { + orgId: 1, + }, + }; + ctx.mount({ + panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + }); + + it('should generate share url absolute time', async () => { + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&viewPanel=22'); + }); + + it('should generate render url', async () => { + mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash'); + ctx.mount({ + panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(state?.imageUrl).toContain(base + params); + }); + + it('should generate render url for scripted dashboard', async () => { + mockLocationHref('http://dashboards.grafana.com/dashboard/script/my-dash.js'); + ctx.mount({ + panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(state?.imageUrl).toContain(base + params); + }); + + it('should remove panel id when no panel in scope', async () => { + ctx.mount({ + panel: undefined, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1'); + }); + + it('should add theme when specified', async () => { + ctx.wrapper?.setProps({ panel: undefined }); + ctx.wrapper?.setState({ selectedTheme: 'light' }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); + }); + + it('should remove editPanel from image url when is first param in querystring', async () => { + mockLocationHref('http://server/#!/test?editPanel=1'); + ctx.mount({ + panel: { id: 1, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain('?editPanel=1&from=1000&to=2000&orgId=1'); + expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); + + it('should shorten url', () => { + mockLocationHref('http://server/#!/test'); + ctx.mount(); + ctx.wrapper?.setState({ useShortUrl: true }, async () => { + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain(`/goto/${mockUid}`); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx new file mode 100644 index 0000000..48bfc3e --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx @@ -0,0 +1,151 @@ +import React, { PureComponent } from 'react'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Field, RadioButtonGroup, Switch, ClipboardButton, Icon, Input, FieldSet, Alert } from '@grafana/ui'; +import { SelectableValue, PanelModel, AppEvents } from '@grafana/data'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { buildImageUrl, buildShareUrl } from './utils'; +import { appEvents } from 'app/core/core'; +import config from 'app/core/config'; + +const themeOptions: Array> = [ + { label: 'Current', value: 'current' }, + { label: 'Dark', value: 'dark' }, + { label: 'Light', value: 'light' }, +]; + +export interface Props { + dashboard: DashboardModel; + panel?: PanelModel; +} + +export interface State { + useCurrentTimeRange: boolean; + useShortUrl: boolean; + selectedTheme: string; + shareUrl: string; + imageUrl: string; +} + +export class ShareLink extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + useCurrentTimeRange: true, + useShortUrl: false, + selectedTheme: 'current', + shareUrl: '', + imageUrl: '', + }; + } + + componentDidMount() { + this.buildUrl(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state; + if ( + prevState.useCurrentTimeRange !== useCurrentTimeRange || + prevState.selectedTheme !== selectedTheme || + prevState.useShortUrl !== useShortUrl + ) { + this.buildUrl(); + } + } + + buildUrl = async () => { + const { panel } = this.props; + const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state; + + const shareUrl = await buildShareUrl(useCurrentTimeRange, selectedTheme, panel, useShortUrl); + const imageUrl = buildImageUrl(useCurrentTimeRange, selectedTheme, panel); + + this.setState({ shareUrl, imageUrl }); + }; + + onUseCurrentTimeRangeChange = () => { + this.setState({ useCurrentTimeRange: !this.state.useCurrentTimeRange }); + }; + + onUrlShorten = () => { + this.setState({ useShortUrl: !this.state.useShortUrl }); + }; + + onThemeChange = (value: string) => { + this.setState({ selectedTheme: value }); + }; + + onShareUrlCopy = () => { + appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + }; + + getShareUrl = () => { + return this.state.shareUrl; + }; + + render() { + const { panel } = this.props; + const isRelativeTime = this.props.dashboard ? this.props.dashboard.time.to === 'now' : false; + const { useCurrentTimeRange, useShortUrl, selectedTheme, shareUrl, imageUrl } = this.state; + const selectors = e2eSelectors.pages.SharePanelModal; + + return ( + <> +

+ Create a direct link to this dashboard or panel, customized with the options below. +

+
+ + + + + + + + + + + + + Copy + + } + /> + +
+ {panel && config.rendererAvailable && ( + + )} + {panel && !config.rendererAvailable && ( + + <>To render a panel image, you must install the + + Grafana image renderer plugin + + . Please contact your Grafana administrator to install the plugin. + + )} + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx new file mode 100644 index 0000000..984d77c --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; +import { ShareLink } from './ShareLink'; +import { ShareSnapshot } from './ShareSnapshot'; +import { ShareExport } from './ShareExport'; +import { ShareEmbed } from './ShareEmbed'; +import { ShareModalTabModel } from './types'; +import { contextSrv } from 'app/core/core'; +import { ShareLibraryPanel } from './ShareLibraryPanel'; + +const customDashboardTabs: ShareModalTabModel[] = []; +const customPanelTabs: ShareModalTabModel[] = []; + +export function addDashboardShareTab(tab: ShareModalTabModel) { + customDashboardTabs.push(tab); +} + +export function addPanelShareTab(tab: ShareModalTabModel) { + customPanelTabs.push(tab); +} + +function getInitialState(props: Props): State { + const tabs = getTabs(props); + return { + tabs, + activeTab: tabs[0].value, + }; +} + +function getTabs(props: Props) { + const { panel } = props; + + const tabs: ShareModalTabModel[] = [{ label: 'Link', value: 'link', component: ShareLink }]; + + if (contextSrv.isSignedIn) { + tabs.push({ label: 'Snapshot', value: 'snapshot', component: ShareSnapshot }); + } + + if (panel) { + tabs.push({ label: 'Embed', value: 'embed', component: ShareEmbed }); + + if (!isPanelModelLibraryPanel(panel)) { + tabs.push({ label: 'Library panel', value: 'library_panel', component: ShareLibraryPanel }); + } + tabs.push(...customPanelTabs); + } else { + tabs.push({ label: 'Export', value: 'export', component: ShareExport }); + tabs.push(...customDashboardTabs); + } + + return tabs; +} + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + + onDismiss(): void; +} + +interface State { + tabs: ShareModalTabModel[]; + activeTab: string; +} + +export class ShareModal extends React.Component { + constructor(props: Props) { + super(props); + this.state = getInitialState(props); + } + + // onDismiss = () => { + // //this.setState(getInitialState(this.props)); + // this.props.onDismiss(); + // }; + + onSelectTab = (t: any) => { + this.setState({ activeTab: t.value }); + }; + + getTabs() { + return getTabs(this.props); + } + + getActiveTab() { + const { tabs, activeTab } = this.state; + return tabs.find((t) => t.value === activeTab)!; + } + + renderTitle() { + const { panel } = this.props; + const { activeTab } = this.state; + const title = panel ? 'Share Panel' : 'Share'; + const tabs = this.getTabs(); + + return ( + + ); + } + + render() { + const { dashboard, panel } = this.props; + const activeTabModel = this.getActiveTab(); + const ActiveTab = activeTabModel.component; + + return ( + + + + + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx new file mode 100644 index 0000000..6d52aee --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -0,0 +1,303 @@ +import React, { PureComponent } from 'react'; +import { Button, ClipboardButton, Icon, Spinner, Select, Input, LinkButton, Field, Modal } from '@grafana/ui'; +import { AppEvents, SelectableValue } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { appEvents } from 'app/core/core'; +import { VariableRefresh } from '../../../variables/types'; + +const snapshotApiUrl = '/api/snapshots'; + +const expireOptions: Array> = [ + { label: 'Never', value: 0 }, + { label: '1 Hour', value: 60 * 60 }, + { label: '1 Day', value: 60 * 60 * 24 }, + { label: '7 Days', value: 60 * 60 * 24 * 7 }, +]; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss(): void; +} + +interface State { + isLoading: boolean; + step: number; + snapshotName: string; + selectedExpireOption: SelectableValue; + snapshotExpires?: number; + snapshotUrl: string; + deleteUrl: string; + timeoutSeconds: number; + externalEnabled: boolean; + sharingButtonText: string; +} + +export class ShareSnapshot extends PureComponent { + private dashboard: DashboardModel; + + constructor(props: Props) { + super(props); + this.dashboard = props.dashboard; + this.state = { + isLoading: false, + step: 1, + selectedExpireOption: expireOptions[0], + snapshotExpires: expireOptions[0].value, + snapshotName: props.dashboard.title, + timeoutSeconds: 4, + snapshotUrl: '', + deleteUrl: '', + externalEnabled: false, + sharingButtonText: '', + }; + } + + componentDidMount() { + this.getSnaphotShareOptions(); + } + + async getSnaphotShareOptions() { + const shareOptions = await getBackendSrv().get('/api/snapshot/shared-options'); + this.setState({ + sharingButtonText: shareOptions['externalSnapshotName'], + externalEnabled: shareOptions['externalEnabled'], + }); + } + + createSnapshot = (external?: boolean) => () => { + const { timeoutSeconds } = this.state; + this.dashboard.snapshot = { + timestamp: new Date(), + }; + + if (!external) { + this.dashboard.snapshot.originalUrl = window.location.href; + } + + this.setState({ isLoading: true }); + this.dashboard.startRefresh(); + + setTimeout(() => { + this.saveSnapshot(this.dashboard, external); + }, timeoutSeconds * 1000); + }; + + saveSnapshot = async (dashboard: DashboardModel, external?: boolean) => { + const { snapshotExpires } = this.state; + const dash = this.dashboard.getSaveModelClone(); + this.scrubDashboard(dash); + + const cmdData = { + dashboard: dash, + name: dash.title, + expires: snapshotExpires, + external: external, + }; + + try { + const results: { deleteUrl: any; url: any } = await getBackendSrv().post(snapshotApiUrl, cmdData); + this.setState({ + deleteUrl: results.deleteUrl, + snapshotUrl: results.url, + step: 2, + }); + } finally { + this.setState({ isLoading: false }); + } + }; + + scrubDashboard = (dash: DashboardModel) => { + const { panel } = this.props; + const { snapshotName } = this.state; + // change title + dash.title = snapshotName; + + // make relative times absolute + dash.time = getTimeSrv().timeRange(); + + // remove panel queries & links + dash.panels.forEach((panel) => { + panel.targets = []; + panel.links = []; + panel.datasource = null; + }); + + // remove annotation queries + const annotations = dash.annotations.list.filter((annotation) => annotation.enable); + dash.annotations.list = annotations.map((annotation: any) => { + return { + name: annotation.name, + enable: annotation.enable, + iconColor: annotation.iconColor, + snapshotData: annotation.snapshotData, + type: annotation.type, + builtIn: annotation.builtIn, + hide: annotation.hide, + }; + }); + + // remove template queries + dash.getVariables().forEach((variable: any) => { + variable.query = ''; + variable.options = variable.current ? [variable.current] : []; + variable.refresh = VariableRefresh.never; + }); + + // snapshot single panel + if (panel) { + const singlePanel = panel.getSaveModel(); + singlePanel.gridPos.w = 24; + singlePanel.gridPos.x = 0; + singlePanel.gridPos.y = 0; + singlePanel.gridPos.h = 20; + dash.panels = [singlePanel]; + } + + // cleanup snapshotData + delete this.dashboard.snapshot; + this.dashboard.forEachPanel((panel: PanelModel) => { + delete panel.snapshotData; + }); + this.dashboard.annotations.list.forEach((annotation) => { + delete annotation.snapshotData; + }); + }; + + deleteSnapshot = async () => { + const { deleteUrl } = this.state; + await getBackendSrv().get(deleteUrl); + this.setState({ step: 3 }); + }; + + getSnapshotUrl = () => { + return this.state.snapshotUrl; + }; + + onSnapshotNameChange = (event: React.ChangeEvent) => { + this.setState({ snapshotName: event.target.value }); + }; + + onTimeoutChange = (event: React.ChangeEvent) => { + this.setState({ timeoutSeconds: Number(event.target.value) }); + }; + + onExpireChange = (option: SelectableValue) => { + this.setState({ + selectedExpireOption: option, + snapshotExpires: option.value, + }); + }; + + onSnapshotUrlCopy = () => { + appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + }; + + renderStep1() { + const { onDismiss } = this.props; + const { + snapshotName, + selectedExpireOption, + timeoutSeconds, + isLoading, + sharingButtonText, + externalEnabled, + } = this.state; + + return ( + <> +
+

+ A snapshot is an instant way to share an interactive dashboard publicly. When created, we strip sensitive + data like queries (metric, template, and annotation) and panel links, leaving only the visible metric data + and series names embedded in your dashboard. +

+

+ Keep in mind, your snapshot can be viewed by anyone that has the link and can access the URL. Share + wisely. +

+
+ + + + + + + + + + {externalEnabled && ( + + )} + + + + ); + } + + renderStep2() { + const { snapshotUrl } = this.state; + + return ( + <> +
+
+ + {snapshotUrl} + +
+ + Copy Link + +
+
+ +
+ Did you make a mistake?{' '} + + Delete snapshot. + +
+ + ); + } + + renderStep3() { + return ( +
+

+ The snapshot has been deleted. If you have already accessed it once, then it might take up to an hour before + before it is removed from browser caches or CDN caches. +

+
+ ); + } + + render() { + const { isLoading, step } = this.state; + + return ( + <> + {step === 1 && this.renderStep1()} + {step === 2 && this.renderStep2()} + {step === 3 && this.renderStep3()} + {isLoading && } + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx new file mode 100644 index 0000000..272d600 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx @@ -0,0 +1,31 @@ +import React, { useCallback } from 'react'; +import { ClipboardButton, CodeEditor, Modal } from '@grafana/ui'; + +import AutoSizer from 'react-virtualized-auto-sizer'; +import { notifyApp } from '../../../../core/actions'; +import { dispatch } from '../../../../store/store'; +import { createSuccessNotification } from '../../../../core/copy/appNotification'; + +export interface ViewJsonModalProps { + json: string; + onDismiss: () => void; +} + +export function ViewJsonModal({ json, onDismiss }: ViewJsonModalProps): JSX.Element { + const getClipboardText = useCallback(() => json, [json]); + const onClipboardCopy = () => { + dispatch(notifyApp(createSuccessNotification('Content copied to clipboard'))); + }; + return ( + + + {({ width }) => } + + + + Copy to Clipboard + + + + ); +} diff --git a/public/app/features/dashboard/components/ShareModal/index.ts b/public/app/features/dashboard/components/ShareModal/index.ts new file mode 100644 index 0000000..2cccea8 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/index.ts @@ -0,0 +1,2 @@ +export { ShareModal, addDashboardShareTab, addPanelShareTab } from './ShareModal'; +export * from './types'; diff --git a/public/app/features/dashboard/components/ShareModal/types.ts b/public/app/features/dashboard/components/ShareModal/types.ts new file mode 100644 index 0000000..e709906 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/types.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import { PanelModel } from '@grafana/data'; +import { DashboardModel, PanelModel as InternalPanelModel } from 'app/features/dashboard/state'; + +export interface ShareModalTabProps { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss?(): void; +} + +type ShareModalTabPropsWithInternalModel = ShareModalTabProps & { panel?: InternalPanelModel }; +export type ShareModalTab = + | React.ComponentType + | React.ComponentType; + +export interface ShareModalTabModel { + label: string; + value: string; + component: ShareModalTab; +} diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts new file mode 100644 index 0000000..51569cc --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -0,0 +1,103 @@ +import { config } from '@grafana/runtime'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { createShortLink } from 'app/core/utils/shortLinks'; +import { PanelModel, dateTime, urlUtil } from '@grafana/data'; + +export function buildParams(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + let params = urlUtil.getUrlSearchParams(); + + const range = getTimeSrv().timeRange(); + params.from = range.from.valueOf(); + params.to = range.to.valueOf(); + params.orgId = config.bootData.user.orgId; + + if (!useCurrentTimeRange) { + delete params.from; + delete params.to; + } + + if (selectedTheme !== 'current') { + params.theme = selectedTheme; + } + + if (panel && !params.editPanel) { + params.viewPanel = panel.id; + } + + return params; +} + +export function buildBaseUrl() { + let baseUrl = window.location.href; + const queryStart = baseUrl.indexOf('?'); + + if (queryStart !== -1) { + baseUrl = baseUrl.substring(0, queryStart); + } + + return baseUrl; +} + +export async function buildShareUrl( + useCurrentTimeRange: boolean, + selectedTheme?: string, + panel?: PanelModel, + shortenUrl?: boolean +) { + const baseUrl = buildBaseUrl(); + const params = buildParams(useCurrentTimeRange, selectedTheme, panel); + const shareUrl = urlUtil.appendQueryToUrl(baseUrl, urlUtil.toUrlParams(params)); + if (shortenUrl) { + return await createShortLink(shareUrl); + } + return shareUrl; +} + +export function buildSoloUrl(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + const baseUrl = buildBaseUrl(); + const params = buildParams(useCurrentTimeRange, selectedTheme, panel); + + let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); + soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); + + params.panelId = params.editPanel ?? params.viewPanel; + delete params.editPanel; + delete params.viewPanel; + + return urlUtil.appendQueryToUrl(soloUrl, urlUtil.toUrlParams(params)); +} + +export function buildImageUrl(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + let soloUrl = buildSoloUrl(useCurrentTimeRange, selectedTheme, panel); + + let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/'); + imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); + imageUrl += '&width=1000&height=500' + getLocalTimeZone(); + return imageUrl; +} + +export function buildIframeHtml(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + let soloUrl = buildSoloUrl(useCurrentTimeRange, selectedTheme, panel); + return ''; +} + +export function getLocalTimeZone() { + const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z')); + + // Older browser does not the internationalization API + if (!(window as any).Intl) { + return utcOffset; + } + + const dateFormat = (window as any).Intl.DateTimeFormat(); + if (!dateFormat.resolvedOptions) { + return utcOffset; + } + + const options = dateFormat.resolvedOptions(); + if (!options.timeZone) { + return utcOffset; + } + + return '&tz=' + encodeURIComponent(options.timeZone); +} diff --git a/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx new file mode 100644 index 0000000..ae2d8ef --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx @@ -0,0 +1,76 @@ +import { AnnotationQuery, EventBus, GrafanaTheme2 } from '@grafana/data'; +import React, { useEffect, useState } from 'react'; +import { getDashboardQueryRunner } from '../../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { AnnotationQueryFinished, AnnotationQueryStarted } from '../../../../types/events'; +import { InlineField, InlineSwitch, useStyles2 } from '@grafana/ui'; +import { LoadingIndicator } from '@grafana/ui/src/components/PanelChrome/LoadingIndicator'; +import { css } from '@emotion/css'; + +export interface AnnotationPickerProps { + events: EventBus; + annotation: AnnotationQuery; + onEnabledChanged: (annotation: AnnotationQuery) => void; +} + +export const AnnotationPicker = ({ annotation, events, onEnabledChanged }: AnnotationPickerProps): JSX.Element => { + const [loading, setLoading] = useState(false); + const styles = useStyles2(getStyles); + const onCancel = () => getDashboardQueryRunner().cancel(annotation); + + useEffect(() => { + const started = events.getStream(AnnotationQueryStarted).subscribe({ + next: (event) => { + if (event.payload === annotation) { + setLoading(true); + } + }, + }); + const stopped = events.getStream(AnnotationQueryFinished).subscribe({ + next: (event) => { + if (event.payload === annotation) { + setLoading(false); + } + }, + }); + + return () => { + started.unsubscribe(); + stopped.unsubscribe(); + }; + }); + + return ( +
+ + <> + onEnabledChanged(annotation)} disabled={loading} /> +
+ +
+ +
+
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + annotation: css` + display: inline-block; + margin-right: ${theme.spacing(1)}; + + .fa-caret-down { + font-size: 75%; + padding-left: ${theme.spacing(1)}; + } + + .gf-form-inline .gf-form { + margin-bottom: 0; + } + `, + indicator: css` + align-self: center; + padding: 0 ${theme.spacing(0.5)}; + `, + }; +} diff --git a/public/app/features/dashboard/components/SubMenu/Annotations.tsx b/public/app/features/dashboard/components/SubMenu/Annotations.tsx new file mode 100644 index 0000000..8f0d30a --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/Annotations.tsx @@ -0,0 +1,33 @@ +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { AnnotationQuery, EventBus } from '@grafana/data'; +import { AnnotationPicker } from './AnnotationPicker'; + +interface Props { + events: EventBus; + annotations: AnnotationQuery[]; + onAnnotationChanged: (annotation: any) => void; +} + +export const Annotations: FunctionComponent = ({ annotations, onAnnotationChanged, events }) => { + const [visibleAnnotations, setVisibleAnnotations] = useState([]); + useEffect(() => { + setVisibleAnnotations(annotations.filter((annotation) => annotation.hide !== true)); + }, [annotations]); + + if (visibleAnnotations.length === 0) { + return null; + } + + return ( + <> + {visibleAnnotations.map((annotation) => ( + + ))} + + ); +}; diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx new file mode 100644 index 0000000..bbcb335 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -0,0 +1,66 @@ +import React, { FC, useReducer } from 'react'; +import { Icon, IconName, Tooltip } from '@grafana/ui'; +import { sanitizeUrl } from '@grafana/data/src/text/sanitize'; +import { DashboardLinksDashboard } from './DashboardLinksDashboard'; +import { getLinkSrv } from '../../../panel/panellinks/link_srv'; + +import { DashboardModel } from '../../state'; +import { DashboardLink } from '../../state/DashboardModel'; +import { linkIconMap } from '../LinksSettings/LinkSettingsEdit'; +import { useEffectOnce } from 'react-use'; +import { CoreEvents } from 'app/types'; +import { selectors } from '@grafana/e2e-selectors'; + +export interface Props { + dashboard: DashboardModel; + links: DashboardLink[]; +} + +export const DashboardLinks: FC = ({ dashboard, links }) => { + // Emulate forceUpdate (https://reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate) + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + useEffectOnce(() => { + dashboard.on(CoreEvents.timeRangeUpdated, forceUpdate); + + return () => { + dashboard.off(CoreEvents.timeRangeUpdated, forceUpdate); + }; + }); + + if (!links.length) { + return null; + } + + return ( + <> + {links.map((link: DashboardLink, index: number) => { + const linkInfo = getLinkSrv().getAnchorInfo(link); + const key = `${link.title}-$${index}`; + + if (link.type === 'dashboards') { + return ; + } + + const linkElement = ( + + + {linkInfo.title} + + ); + + return ( +
+ {link.tooltip ? {linkElement} : linkElement} +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx new file mode 100644 index 0000000..23d17ef --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx @@ -0,0 +1,118 @@ +import { DashboardLink } from '../../state/DashboardModel'; +import { DashboardSearchHit, DashboardSearchItemType } from '../../../search/types'; +import { resolveLinks, searchForTags } from './DashboardLinksDashboard'; +import { describe, expect } from '../../../../../test/lib/common'; + +describe('searchForTags', () => { + const setupTestContext = () => { + const tags = ['A', 'B']; + const link: DashboardLink = { + targetBlank: false, + keepTime: false, + includeVars: false, + asDropdown: false, + icon: 'some icon', + tags, + title: 'some title', + tooltip: 'some tooltip', + type: 'dashboards', + url: '/d/6ieouugGk/DashLinks', + }; + const backendSrv: any = { + search: jest.fn((args) => []), + }; + + return { link, backendSrv }; + }; + + describe('when called', () => { + it('then tags from link should be used in search and limit should be 100', async () => { + const { link, backendSrv } = setupTestContext(); + + const results = await searchForTags(link.tags, { getBackendSrv: () => backendSrv }); + + expect(results.length).toEqual(0); + expect(backendSrv.search).toHaveBeenCalledWith({ tag: ['A', 'B'], limit: 100 }); + expect(backendSrv.search).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('resolveLinks', () => { + const setupTestContext = (dashboardId: number, searchHitId: number) => { + const link: DashboardLink = { + targetBlank: false, + keepTime: false, + includeVars: false, + asDropdown: false, + icon: 'some icon', + tags: [], + title: 'some title', + tooltip: 'some tooltip', + type: 'dashboards', + url: '/d/6ieouugGk/DashLinks', + }; + const searchHits: DashboardSearchHit[] = [ + { + id: searchHitId, + title: 'DashLinks', + url: '/d/6ieouugGk/DashLinks', + isStarred: false, + items: [], + tags: [], + uri: 'db/DashLinks', + type: DashboardSearchItemType.DashDB, + }, + ]; + const linkSrv: any = { + getLinkUrl: jest.fn((args) => args.url), + }; + const sanitize = jest.fn((args) => args); + const sanitizeUrl = jest.fn((args) => args); + + return { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl }; + }; + + describe('when called', () => { + it('should filter out the calling dashboardId', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 1); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(0); + expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0); + expect(sanitize).toHaveBeenCalledTimes(0); + expect(sanitizeUrl).toHaveBeenCalledTimes(0); + }); + + it('should resolve link url', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1); + expect(linkSrv.getLinkUrl).toHaveBeenCalledWith({ ...link, url: searchHits[0].url }); + }); + + it('should sanitize title', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(sanitize).toHaveBeenCalledTimes(1); + expect(sanitize).toHaveBeenCalledWith(searchHits[0].title); + }); + + it('should sanitize url', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(sanitizeUrl).toHaveBeenCalledTimes(1); + expect(sanitizeUrl).toHaveBeenCalledWith(searchHits[0].url); + }); + }); +}); diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx new file mode 100644 index 0000000..576e333 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -0,0 +1,167 @@ +import React, { useRef, useState } from 'react'; +import { Icon, Tooltip } from '@grafana/ui'; +import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { getLinkSrv } from '../../../panel/panellinks/link_srv'; +import { DashboardLink } from '../../state/DashboardModel'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { selectors } from '@grafana/e2e-selectors'; +import { useAsync } from 'react-use'; + +interface Props { + link: DashboardLink; + linkInfo: { title: string; href: string }; + dashboardId: any; +} + +export const DashboardLinksDashboard: React.FC = (props) => { + const { link, linkInfo } = props; + const listRef = useRef(null); + const [opened, setOpened] = useState(0); + const resolvedLinks = useResolvedLinks(props, opened); + + if (link.asDropdown) { + return ( + + <> + setOpened(Date.now())} + className="gf-form-label gf-form-label--dashlink" + data-placement="bottom" + data-toggle="dropdown" + > + + {linkInfo.title} + +
    + {resolvedLinks.length > 0 && + resolvedLinks.map((resolvedLink, index) => { + return ( +
  • + + {resolvedLink.title} + +
  • + ); + })} +
+ +
+ ); + } + + return ( + <> + {resolvedLinks.length > 0 && + resolvedLinks.map((resolvedLink, index) => { + return ( + + + + {resolvedLink.title} + + + ); + })} + + ); +}; + +interface LinkElementProps { + link: DashboardLink; + 'aria-label': string; + key: string; + children: JSX.Element; +} + +const LinkElement: React.FC = (props) => { + const { link, children, ...rest } = props; + + return ( +
+ {link.tooltip && {children}} + {!link.tooltip && <>{children}} +
+ ); +}; + +const useResolvedLinks = ({ link, dashboardId }: Props, opened: number): ResolvedLinkDTO[] => { + const { tags } = link; + const result = useAsync(() => searchForTags(tags), [tags, opened]); + if (!result.value) { + return []; + } + return resolveLinks(dashboardId, link, result.value); +}; + +interface ResolvedLinkDTO { + id: any; + url: string; + title: string; +} + +export async function searchForTags( + tags: any[], + dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv } +): Promise { + const limit = 100; + const searchHits: DashboardSearchHit[] = await dependencies.getBackendSrv().search({ tag: tags, limit }); + + return searchHits; +} + +export function resolveLinks( + dashboardId: any, + link: DashboardLink, + searchHits: DashboardSearchHit[], + dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = { + getLinkSrv, + sanitize, + sanitizeUrl, + } +): ResolvedLinkDTO[] { + return searchHits + .filter((searchHit) => searchHit.id !== dashboardId) + .map((searchHit) => { + const id = searchHit.id; + const title = dependencies.sanitize(searchHit.title); + const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url }); + const url = dependencies.sanitizeUrl(resolvedLink); + + return { id, title, url }; + }); +} + +function getDropdownLocationCssClass(element: HTMLElement | null) { + if (!element) { + return 'invisible'; + } + + const wrapperPos = element.parentElement!.getBoundingClientRect(); + const pos = element.getBoundingClientRect(); + + if (pos.width === 0) { + return 'invisible'; + } + + if (wrapperPos.left + pos.width + 10 > window.innerWidth) { + return 'pull-left'; + } else { + return 'pull-right'; + } +} diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx new file mode 100644 index 0000000..2fe5da1 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -0,0 +1,72 @@ +import React, { PureComponent } from 'react'; +import { connect, MapStateToProps } from 'react-redux'; +import { StoreState } from '../../../../types'; +import { getSubMenuVariables } from '../../../variables/state/selectors'; +import { VariableModel } from '../../../variables/types'; +import { DashboardModel } from '../../state'; +import { DashboardLinks } from './DashboardLinks'; +import { Annotations } from './Annotations'; +import { SubMenuItems } from './SubMenuItems'; +import { DashboardLink } from '../../state/DashboardModel'; +import { AnnotationQuery } from '@grafana/data'; + +interface OwnProps { + dashboard: DashboardModel; + links: DashboardLink[]; + annotations: AnnotationQuery[]; +} + +interface ConnectedProps { + variables: VariableModel[]; +} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +class SubMenuUnConnected extends PureComponent { + onAnnotationStateChanged = (updatedAnnotation: any) => { + // we're mutating dashboard state directly here until annotations are in Redux. + for (let index = 0; index < this.props.dashboard.annotations.list.length; index++) { + const annotation = this.props.dashboard.annotations.list[index]; + if (annotation.name === updatedAnnotation.name) { + annotation.enable = !annotation.enable; + break; + } + } + this.props.dashboard.startRefresh(); + this.forceUpdate(); + }; + + render() { + const { dashboard, variables, links, annotations } = this.props; + + if (!dashboard.isSubMenuVisible()) { + return null; + } + + return ( +
+ + +
+ {dashboard && } +
+
+ ); + } +} + +const mapStateToProps: MapStateToProps = (state) => { + return { + variables: getSubMenuVariables(state.templating.variables), + }; +}; + +export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected); + +SubMenu.displayName = 'SubMenu'; diff --git a/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx b/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx new file mode 100644 index 0000000..ce20a18 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx @@ -0,0 +1,35 @@ +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { VariableHide, VariableModel } from '../../../variables/types'; +import { selectors } from '@grafana/e2e-selectors'; +import { PickerRenderer } from '../../../variables/pickers/PickerRenderer'; + +interface Props { + variables: VariableModel[]; +} + +export const SubMenuItems: FunctionComponent = ({ variables }) => { + const [visibleVariables, setVisibleVariables] = useState([]); + useEffect(() => { + setVisibleVariables(variables.filter((state) => state.hide !== VariableHide.hideVariable)); + }, [variables]); + + if (visibleVariables.length === 0) { + return null; + } + + return ( + <> + {visibleVariables.map((variable) => { + return ( +
+ +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx new file mode 100644 index 0000000..64b626a --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { mergeMap } from 'rxjs/operators'; +import { css } from '@emotion/css'; +import { Icon, JSONFormatter, useStyles } from '@grafana/ui'; +import { + DataFrame, + DataTransformerConfig, + GrafanaTheme, + transformDataFrame, + TransformerRegistryItem, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { TransformationsEditorTransformation } from './types'; + +interface TransformationEditorProps { + debugMode?: boolean; + index: number; + data: DataFrame[]; + uiConfig: TransformerRegistryItem; + configs: TransformationsEditorTransformation[]; + onChange: (index: number, config: DataTransformerConfig) => void; +} + +export const TransformationEditor = ({ + debugMode, + index, + data, + uiConfig, + configs, + onChange, +}: TransformationEditorProps) => { + const styles = useStyles(getStyles); + const [input, setInput] = useState([]); + const [output, setOutput] = useState([]); + const config = useMemo(() => configs[index], [configs, index]); + + useEffect(() => { + const inputTransforms = configs.slice(0, index).map((t) => t.transformation); + const outputTransforms = configs.slice(index, index + 1).map((t) => t.transformation); + const inputSubscription = transformDataFrame(inputTransforms, data).subscribe(setInput); + const outputSubscription = transformDataFrame(inputTransforms, data) + .pipe(mergeMap((before) => transformDataFrame(outputTransforms, before))) + .subscribe(setOutput); + + return function unsubscribe() { + inputSubscription.unsubscribe(); + outputSubscription.unsubscribe(); + }; + }, [index, data, configs]); + + const editor = useMemo( + () => + React.createElement(uiConfig.editor, { + options: { ...uiConfig.transformation.defaultOptions, ...config.transformation.options }, + input, + onChange: (opts: any) => { + onChange(index, { id: config.transformation.id, options: opts }); + }, + }), + [ + uiConfig.editor, + uiConfig.transformation.defaultOptions, + config.transformation.options, + config.transformation.id, + input, + onChange, + index, + ] + ); + + return ( +
+ {editor} + {debugMode && ( +
+
+
Transformation input data
+
+ +
+
+
+ +
+
+
Transformation output data
+
{output && }
+
+
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + const debugBorder = theme.isLight ? theme.palette.gray85 : theme.palette.gray15; + + return { + title: css` + display: flex; + padding: 4px 8px 4px 8px; + position: relative; + height: 35px; + border-radius: 4px 4px 0 0; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; + `, + name: css` + font-weight: ${theme.typography.weight.semibold}; + color: ${theme.colors.textBlue}; + `, + iconRow: css` + display: flex; + `, + icon: css` + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; + color: ${theme.colors.textWeak}; + margin-left: ${theme.spacing.sm}; + &:hover { + color: ${theme.colors.text}; + } + `, + editor: css``, + debugWrapper: css` + display: flex; + flex-direction: row; + `, + debugSeparator: css` + width: 48px; + min-height: 300px; + display: flex; + align-items: center; + align-self: stretch; + justify-content: center; + margin: 0 ${theme.spacing.xs}; + color: ${theme.colors.textBlue}; + `, + debugTitle: css` + padding: ${theme.spacing.sm} ${theme.spacing.xxs}; + font-family: ${theme.typography.fontFamily.monospace}; + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.text}; + border-bottom: 1px solid ${debugBorder}; + flex-grow: 0; + flex-shrink: 1; + `, + + debug: css` + margin-top: ${theme.spacing.sm}; + padding: 0 ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm}; + border: 1px solid ${debugBorder}; + background: ${theme.isLight ? theme.palette.white : theme.palette.gray05}; + border-radius: ${theme.border.radius.sm}; + width: 100%; + min-height: 300px; + display: flex; + flex-direction: column; + align-self: stretch; + `, + debugJson: css` + flex-grow: 1; + height: 100%; + overflow: hidden; + padding: ${theme.spacing.xs}; + `, + }; +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx new file mode 100644 index 0000000..277becf --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { DataFrame, DataTransformerConfig, TransformerRegistryItem } from '@grafana/data'; +import { HorizontalGroup } from '@grafana/ui'; + +import { TransformationEditor } from './TransformationEditor'; +import { + QueryOperationRow, + QueryOperationRowRenderProps, +} from 'app/core/components/QueryOperationRow/QueryOperationRow'; +import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; +import { TransformationsEditorTransformation } from './types'; + +interface TransformationOperationRowProps { + id: string; + index: number; + data: DataFrame[]; + uiConfig: TransformerRegistryItem; + configs: TransformationsEditorTransformation[]; + onRemove: (index: number) => void; + onChange: (index: number, config: DataTransformerConfig) => void; +} + +export const TransformationOperationRow: React.FC = ({ + onRemove, + index, + id, + data, + configs, + uiConfig, + onChange, +}) => { + const [showDebug, setShowDebug] = useState(false); + + const renderActions = ({ isOpen }: QueryOperationRowRenderProps) => { + return ( + + { + setShowDebug(!showDebug); + }} + /> + + onRemove(index)} /> + + ); + }; + + return ( + + + + ); +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx new file mode 100644 index 0000000..6477e0e --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { DataFrame, DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; + +import { TransformationOperationRow } from './TransformationOperationRow'; +import { TransformationsEditorTransformation } from './types'; + +interface TransformationOperationRowsProps { + data: DataFrame[]; + configs: TransformationsEditorTransformation[]; + onRemove: (index: number) => void; + onChange: (index: number, config: DataTransformerConfig) => void; +} + +export const TransformationOperationRows: React.FC = ({ + data, + onChange, + onRemove, + configs, +}) => { + return ( + <> + {configs.map((t, i) => { + const uiConfig = standardTransformersRegistry.getIfExists(t.transformation.id); + if (!uiConfig) { + return null; + } + + return ( + + ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx new file mode 100644 index 0000000..5269a9a --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TransformationsEditor } from './TransformationsEditor'; +import { PanelModel } from '../../state'; +import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { selectors } from '@grafana/e2e-selectors'; + +const setup = (transformations: DataTransformerConfig[] = []) => { + const panel = new PanelModel({}); + panel.setTransformations(transformations); + render(); +}; + +describe('TransformationsEditor', () => { + standardTransformersRegistry.setInit(getStandardTransformers); + + describe('when no transformations configured', () => { + it('renders transformations selection list', () => { + setup(); + + const cards = screen.getAllByLabelText(/^New transform/i); + expect(cards.length).toEqual(standardTransformersRegistry.list().length); + }); + }); + + describe('when transformations configured', () => { + it('renders transformation editors', () => { + setup([ + { + id: 'reduce', + options: {}, + }, + ]); + const editors = screen.getAllByLabelText(/^Transformation editor/g); + expect(editors).toHaveLength(1); + }); + }); + + describe('when Add transformation clicked', () => { + it('renders transformations picker', () => { + const buttonLabel = 'Add transformation'; + setup([ + { + id: 'reduce', + options: {}, + }, + ]); + + const addTransformationButton = screen.getByText(buttonLabel); + userEvent.click(addTransformationButton); + + const search = screen.getByLabelText(selectors.components.Transforms.searchInput); + expect(search).toBeDefined(); + }); + }); + + describe('actions', () => { + describe('debug', () => { + it('should show/hide debugger', () => { + setup([ + { + id: 'reduce', + options: {}, + }, + ]); + const debuggerSelector = selectors.components.TransformTab.transformationEditorDebugger('Reduce'); + + expect(screen.queryByLabelText(debuggerSelector)).toBeNull(); + + const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug')); + userEvent.click(debugButton); + + expect(screen.getByLabelText(debuggerSelector)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx new file mode 100644 index 0000000..88733ad --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -0,0 +1,393 @@ +import React, { ChangeEvent } from 'react'; +import { + Alert, + Button, + Container, + CustomScrollbar, + Themeable, + VerticalGroup, + withTheme, + Input, + IconButton, + useStyles2, +} from '@grafana/ui'; +import { + DataFrame, + DataTransformerConfig, + DocsId, + GrafanaTheme2, + PanelData, + SelectableValue, + standardTransformersRegistry, +} from '@grafana/data'; +import { Card, CardProps } from '../../../../core/components/Card/Card'; +import { css } from '@emotion/css'; +import { selectors } from '@grafana/e2e-selectors'; +import { Unsubscribable } from 'rxjs'; +import { PanelModel } from '../../state'; +import { getDocsLink } from 'app/core/utils/docsLinks'; +import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; +import { TransformationOperationRows } from './TransformationOperationRows'; +import { TransformationsEditorTransformation } from './types'; +import { PanelNotSupported } from '../PanelEditor/PanelNotSupported'; +import { AppNotificationSeverity } from '../../../../types'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed'; + +interface TransformationsEditorProps extends Themeable { + panel: PanelModel; +} + +interface State { + data: DataFrame[]; + transformations: TransformationsEditorTransformation[]; + search: string; + showPicker?: boolean; +} + +class UnThemedTransformationsEditor extends React.PureComponent { + subscription?: Unsubscribable; + + constructor(props: TransformationsEditorProps) { + super(props); + const transformations = props.panel.transformations || []; + + const ids = this.buildTransformationIds(transformations); + this.state = { + transformations: transformations.map((t, i) => ({ + transformation: t, + id: ids[i], + })), + data: [], + search: '', + }; + } + + onSearchChange = (event: ChangeEvent) => { + this.setState({ search: event.target.value }); + }; + + onSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + const { search } = this.state; + if (search) { + const lower = search.toLowerCase(); + const filtered = standardTransformersRegistry.list().filter((t) => { + const txt = (t.name + t.description).toLowerCase(); + return txt.indexOf(lower) >= 0; + }); + if (filtered.length > 0) { + this.onTransformationAdd({ value: filtered[0].id }); + } + } + } else if (event.keyCode === 27) { + // Escape key + this.setState({ search: '', showPicker: false }); + event.stopPropagation(); // don't exit the editor + } + }; + + buildTransformationIds(transformations: DataTransformerConfig[]) { + const transformationCounters: Record = {}; + const transformationIds: string[] = []; + + for (let i = 0; i < transformations.length; i++) { + const transformation = transformations[i]; + if (transformationCounters[transformation.id] === undefined) { + transformationCounters[transformation.id] = 0; + } else { + transformationCounters[transformation.id] += 1; + } + transformationIds.push(`${transformations[i].id}-${transformationCounters[transformations[i].id]}`); + } + return transformationIds; + } + + componentDidMount() { + this.subscription = this.props.panel + .getQueryRunner() + .getData({ withTransforms: false, withFieldConfig: false }) + .subscribe({ + next: (panelData: PanelData) => this.setState({ data: panelData.series }), + }); + } + + componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + onChange(transformations: TransformationsEditorTransformation[]) { + this.setState({ transformations }); + this.props.panel.setTransformations(transformations.map((t) => t.transformation)); + } + + // Transformation UIDs are stored in a name-X form. name is NOT unique hence we need to parse the IDs and increase X + // for transformations with the same name + getTransformationNextId = (name: string) => { + const { transformations } = this.state; + let nextId = 0; + const existingIds = transformations.filter((t) => t.id.startsWith(name)).map((t) => t.id); + + if (existingIds.length !== 0) { + nextId = Math.max(...existingIds.map((i) => parseInt(i.match(/\d+/)![0], 10))) + 1; + } + + return `${name}-${nextId}`; + }; + + onTransformationAdd = (selectable: SelectableValue) => { + const { transformations } = this.state; + + const nextId = this.getTransformationNextId(selectable.value!); + this.setState({ search: '', showPicker: false }); + this.onChange([ + ...transformations, + { + id: nextId, + transformation: { + id: selectable.value as string, + options: {}, + }, + }, + ]); + }; + + onTransformationChange = (idx: number, config: DataTransformerConfig) => { + const { transformations } = this.state; + const next = Array.from(transformations); + next[idx].transformation = config; + this.onChange(next); + }; + + onTransformationRemove = (idx: number) => { + const { transformations } = this.state; + const next = Array.from(transformations); + next.splice(idx, 1); + this.onChange(next); + }; + + onDragEnd = (result: DropResult) => { + const { transformations } = this.state; + + if (!result || !result.destination) { + return; + } + + const startIndex = result.source.index; + const endIndex = result.destination.index; + if (startIndex === endIndex) { + return; + } + const update = Array.from(transformations); + const [removed] = update.splice(startIndex, 1); + update.splice(endIndex, 0, removed); + this.onChange(update); + }; + + renderTransformationEditors = () => { + const { data, transformations } = this.state; + + return ( + + + {(provided) => { + return ( +
+ + {provided.placeholder} +
+ ); + }} +
+
+ ); + }; + + renderTransformsPicker() { + const { transformations, search } = this.state; + let suffix: React.ReactNode = null; + let xforms = standardTransformersRegistry.list(); + if (search) { + const lower = search.toLowerCase(); + const filtered = xforms.filter((t) => { + const txt = (t.name + t.description).toLowerCase(); + return txt.indexOf(lower) >= 0; + }); + suffix = ( + <> + {filtered.length} / {xforms.length}    + { + this.setState({ search: '' }); + }} + /> + + ); + + xforms = filtered; + } + + const noTransforms = !transformations?.length; + const showPicker = noTransforms || this.state.showPicker; + if (!suffix && showPicker && !noTransforms) { + suffix = ( + { + this.setState({ showPicker: false }); + }} + /> + ); + } + + return ( + <> + {noTransforms && ( + + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + + return ( + { + onDismiss(true); + }} + severity="info" + > +

+ Transformations allow you to join, calculate, re-order, hide, and rename your query results before + they are visualized.
+ Many transforms are not suitable if you're using the Graph visualization, as it currently + only only supports time series data.
+ It can help to switch to the Table visualization to understand what a transformation is doing.{' '} +

+ + Read more + +
+ ); + }} + +
+ )} + {showPicker ? ( + + + + {xforms.map((t) => { + return ( + Select} + ariaLabel={selectors.components.TransformTab.newTransform(t.name)} + onClick={() => { + this.onTransformationAdd({ value: t.id }); + }} + /> + ); + })} + + ) : ( + + )} + + ); + } + + render() { + const { + panel: { alert }, + } = this.props; + const { transformations } = this.state; + + const hasTransforms = transformations.length > 0; + + if (!hasTransforms && alert) { + return ; + } + + return ( + + +
+ {hasTransforms && alert ? ( + + ) : null} + {hasTransforms && this.renderTransformationEditors()} + {this.renderTransformsPicker()} +
+
+
+ ); + } +} + +const TransformationCard: React.FC = (props) => { + const styles = useStyles2(getStyles); + return ; +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + card: css` + background: ${theme.colors.background.secondary}; + width: 100%; + border: none; + padding: ${theme.spacing(1)}; + + // hack because these cards use classes from a very different card for some reason + .add-data-source-item-text { + font-size: ${theme.typography.size.md}; + } + + &:hover { + background: ${theme.colors.action.hover}; + box-shadow: none; + border: none; + } + `, + }; +}; + +export const TransformationsEditor = withTheme(UnThemedTransformationsEditor); diff --git a/public/app/features/dashboard/components/TransformationsEditor/types.ts b/public/app/features/dashboard/components/TransformationsEditor/types.ts new file mode 100644 index 0000000..50ff63d --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/types.ts @@ -0,0 +1,6 @@ +import { DataTransformerConfig } from '@grafana/data'; + +export interface TransformationsEditorTransformation { + transformation: DataTransformerConfig; + id: string; +} diff --git a/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx b/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx new file mode 100644 index 0000000..cfe61de --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { last } from 'lodash'; +import { useStyles } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; +import { DiffTitle } from './DiffTitle'; +import { DiffValues } from './DiffValues'; +import { Diff, getDiffText } from './utils'; + +type DiffGroupProps = { + diffs: Diff[]; + title: string; +}; + +export const DiffGroup: React.FC = ({ diffs, title }) => { + const styles = useStyles(getStyles); + + if (diffs.length === 1) { + return ( +
+ +
+ ); + } + + return ( +
+ +
    + {diffs.map((diff: Diff, idx: number) => { + return ( +
  • + {getDiffText(diff)} +
  • + ); + })} +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + container: css` + background-color: ${theme.colors.bg2}; + font-size: ${theme.typography.size.md}; + margin-bottom: ${theme.spacing.md}; + padding: ${theme.spacing.md}; + `, + list: css` + margin-left: ${theme.spacing.xl}; + `, + listItem: css` + margin-bottom: ${theme.spacing.sm}; + `, +}); diff --git a/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx b/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx new file mode 100644 index 0000000..1637b4b --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useStyles, Icon } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; +import { Diff, getDiffText } from './utils'; +import { DiffValues } from './DiffValues'; + +type DiffTitleProps = { + diff?: Diff; + title: string; +}; + +const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 }; + +export const DiffTitle: React.FC = ({ diff, title }) => { + const styles = useStyles(getDiffTitleStyles); + return diff ? ( + <> + {title}{' '} + {getDiffText(diff, diff.path.length > 1)} + + ) : ( +
+ {title}{' '} + {getDiffText(replaceDiff, false)} +
+ ); +}; + +const getDiffTitleStyles = (theme: GrafanaTheme) => ({ + embolden: css` + font-weight: ${theme.typography.weight.bold}; + `, + add: css` + color: ${theme.palette.online}; + `, + replace: css` + color: ${theme.palette.warn}; + `, + move: css` + color: ${theme.palette.warn}; + `, + copy: css` + color: ${theme.palette.warn}; + `, + _get: css` + color: ${theme.palette.warn}; + `, + test: css` + color: ${theme.palette.warn}; + `, + remove: css` + color: ${theme.palette.critical}; + `, + withoutDiff: css` + margin-bottom: ${theme.spacing.md}; + `, +}); diff --git a/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx b/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx new file mode 100644 index 0000000..f7e9012 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { isArray, isObject, isUndefined } from 'lodash'; +import { useStyles2, Icon } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import { Diff } from './utils'; + +type DiffProps = { + diff: Diff; +}; + +export const DiffValues: React.FC = ({ diff }) => { + const styles = useStyles2(getStyles); + const hasLeftValue = + !isUndefined(diff.originalValue) && !isArray(diff.originalValue) && !isObject(diff.originalValue); + const hasRightValue = !isUndefined(diff.value) && !isArray(diff.value) && !isObject(diff.value); + + return ( + <> + {hasLeftValue && {String(diff.originalValue)}} + {hasLeftValue && hasRightValue ? : null} + {hasRightValue && {String(diff.value)}} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => css` + background-color: ${theme.colors.action.hover}; + border-radius: ${theme.shape.borderRadius()}; + color: ${theme.colors.text.primary}; + font-size: ${theme.typography.body.fontSize}; + margin: 0 ${theme.spacing(0.5)}; + padding: ${theme.spacing(0.5, 1)}; +`; diff --git a/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx b/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx new file mode 100644 index 0000000..0cf5be1 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer'; +import { useTheme } from '@grafana/ui'; +import tinycolor from 'tinycolor2'; + +export const DiffViewer: React.FC = ({ oldValue, newValue }) => { + const theme = useTheme(); + + const styles = { + variables: { + // the light theme supplied by ReactDiffViewer is very similar to Grafana + // the dark theme needs some tweaks. + dark: { + diffViewerBackground: theme.colors.dashboardBg, + diffViewerColor: theme.colors.text, + addedBackground: tinycolor(theme.palette.greenShade).setAlpha(0.3).toString(), + addedColor: 'white', + removedBackground: tinycolor(theme.palette.redShade).setAlpha(0.3).toString(), + removedColor: 'white', + wordAddedBackground: tinycolor(theme.palette.greenBase).setAlpha(0.4).toString(), + wordRemovedBackground: tinycolor(theme.palette.redBase).setAlpha(0.4).toString(), + addedGutterBackground: tinycolor(theme.palette.greenShade).setAlpha(0.2).toString(), + removedGutterBackground: tinycolor(theme.palette.redShade).setAlpha(0.2).toString(), + gutterBackground: theme.colors.bg1, + gutterBackgroundDark: theme.colors.bg1, + highlightBackground: tinycolor(theme.colors.bgBlue1).setAlpha(0.4).toString(), + highlightGutterBackground: tinycolor(theme.colors.bgBlue2).setAlpha(0.2).toString(), + codeFoldGutterBackground: theme.colors.bg2, + codeFoldBackground: theme.colors.bg2, + emptyLineBackground: theme.colors.bg2, + gutterColor: theme.colors.textFaint, + addedGutterColor: theme.colors.text, + removedGutterColor: theme.colors.text, + codeFoldContentColor: theme.colors.textFaint, + diffViewerTitleBackground: theme.colors.bg2, + diffViewerTitleColor: theme.colors.textFaint, + diffViewerTitleBorderColor: theme.colors.border3, + }, + }, + codeFold: { + fontSize: theme.typography.size.sm, + }, + }; + + return ( +
+ +
+ ); +}; diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts new file mode 100644 index 0000000..bbc9c4c --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts @@ -0,0 +1,74 @@ +import { restore, versions } from './__mocks__/dashboardHistoryMocks'; +import { HistorySrv } from './HistorySrv'; +import { DashboardModel } from '../../state/DashboardModel'; + +const getMock = jest.fn().mockResolvedValue({}); +const postMock = jest.fn().mockResolvedValue({}); + +jest.mock('app/core/store'); +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + + return { + ...original, + getBackendSrv: () => ({ + post: postMock, + get: getMock, + }), + }; +}); + +describe('historySrv', () => { + const versionsResponse = versions(); + const restoreResponse = restore; + + let historySrv = new HistorySrv(); + + const dash = new DashboardModel({ id: 1 }); + const emptyDash = new DashboardModel({}); + const historyListOpts = { limit: 10, start: 0 }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getHistoryList', () => { + it('should return a versions array for the given dashboard id', () => { + getMock.mockImplementation(() => Promise.resolve(versionsResponse)); + historySrv = new HistorySrv(); + + return historySrv.getHistoryList(dash, historyListOpts).then((versions: any) => { + expect(versions).toEqual(versionsResponse); + }); + }); + + it('should return an empty array when not given an id', () => { + return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions: any) => { + expect(versions).toEqual([]); + }); + }); + + it('should return an empty array when not given a dashboard', () => { + return historySrv.getHistoryList((null as unknown) as DashboardModel, historyListOpts).then((versions: any) => { + expect(versions).toEqual([]); + }); + }); + }); + + describe('restoreDashboard', () => { + it('should return a success response given valid parameters', () => { + const version = 6; + postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); + historySrv = new HistorySrv(); + return historySrv.restoreDashboard(dash, version).then((response: any) => { + expect(response).toEqual(restoreResponse(version)); + }); + }); + + it('should return an empty object when not given an id', async () => { + historySrv = new HistorySrv(); + const rsp = await historySrv.restoreDashboard(emptyDash, 6); + expect(rsp).toEqual({}); + }); + }); +}); diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts new file mode 100644 index 0000000..1982f07 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts @@ -0,0 +1,49 @@ +import { isNumber } from 'lodash'; +import coreModule from 'app/core/core_module'; +import { DashboardModel } from '../../state/DashboardModel'; +import { getBackendSrv } from '@grafana/runtime'; + +export interface HistoryListOpts { + limit: number; + start: number; +} + +export interface RevisionsModel { + id: number; + checked: boolean; + dashboardId: number; + parentVersion: number; + version: number; + created: Date; + createdBy: string; + message: string; +} + +export interface DiffTarget { + dashboardId: number; + version: number; + unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version +} + +export class HistorySrv { + getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + return id ? getBackendSrv().get(`api/dashboards/id/${id}/versions`, options) : Promise.resolve([]); + } + + getDashboardVersion(id: number, version: number) { + return getBackendSrv().get(`api/dashboards/id/${id}/versions/${version}`); + } + + restoreDashboard(dashboard: DashboardModel, version: number) { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + const url = `api/dashboards/id/${id}/restore`; + + return id && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({}); + } +} + +const historySrv = new HistorySrv(); +export { historySrv }; + +coreModule.service('historySrv', HistorySrv); diff --git a/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx new file mode 100644 index 0000000..f4f2641 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react'; +import { ConfirmModal } from '@grafana/ui'; +import { useDashboardRestore } from './useDashboardRestore'; +export interface RevertDashboardModalProps { + hideModal: () => void; + version: number; +} + +export const RevertDashboardModal: React.FC = ({ hideModal, version }) => { + // TODO: how should state.error be handled? + const { state, onRestoreDashboard } = useDashboardRestore(version); + + useEffect(() => { + if (state.loading === false && state.value) { + hideModal(); + } + }, [state, hideModal]); + + return ( + Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.

+ } + confirmText={`Yes, restore to version ${version}`} + /> + ); +}; diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx new file mode 100644 index 0000000..b720d72 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { HorizontalGroup, Tooltip, Button } from '@grafana/ui'; + +type VersionsButtonsType = { + hasMore: boolean; + canCompare: boolean; + getVersions: (append: boolean) => void; + getDiff: () => void; + isLastPage: boolean; +}; +export const VersionsHistoryButtons: React.FC = ({ + hasMore, + canCompare, + getVersions, + getDiff, + isLastPage, +}) => ( + + {hasMore && ( + + )} + + + + +); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx new file mode 100644 index 0000000..e132b20 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { css, cx } from '@emotion/css'; + +import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles } from '@grafana/ui'; +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; +import { RevertDashboardModal } from './RevertDashboardModal'; +import { DiffGroup } from './DiffGroup'; +import { DiffViewer } from './DiffViewer'; +import { jsonDiff } from './utils'; +import { GrafanaTheme } from '@grafana/data'; + +type DiffViewProps = { + isNewLatest: boolean; + newInfo: DecoratedRevisionModel; + baseInfo: DecoratedRevisionModel; + diffData: { lhs: any; rhs: any }; +}; + +export const VersionHistoryComparison: React.FC = ({ baseInfo, newInfo, diffData, isNewLatest }) => { + const diff = jsonDiff(diffData.lhs, diffData.rhs); + const styles = useStyles(getStyles); + + return ( +
+
+ +
+

+ Version {newInfo.version} updated by {newInfo.createdBy} {newInfo.ageString} -{' '} + {newInfo.message} +

+

+ Version {baseInfo.version} updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '} + {baseInfo.message} +

+
+ {isNewLatest && ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+
+
+ {Object.entries(diff).map(([key, diffs]) => ( + + ))} +
+ + + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + spacer: css` + margin-bottom: ${theme.spacing.xl}; + `, + versionInfo: css` + color: ${theme.colors.textWeak}; + font-size: ${theme.typography.size.sm}; + `, + noMarginBottom: css` + margin-bottom: 0; + `, +}); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx new file mode 100644 index 0000000..8ad1cd2 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { noop } from 'lodash'; +import { GrafanaTheme } from '@grafana/data'; +import { Icon, useStyles } from '@grafana/ui'; + +type VersionHistoryHeaderProps = { + isComparing?: boolean; + onClick?: () => void; + baseVersion?: number; + newVersion?: number; + isNewLatest?: boolean; +}; + +export const VersionHistoryHeader: React.FC = ({ + isComparing = false, + onClick = noop, + baseVersion = 0, + newVersion = 0, + isNewLatest = false, +}) => { + const styles = useStyles(getStyles); + + return ( +

+ + Versions + + {isComparing && ( + + Comparing {baseVersion} {newVersion}{' '} + {isNewLatest && (Latest)} + + )} +

+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + header: css` + font-size: ${theme.typography.heading.h3}; + margin-bottom: ${theme.spacing.lg}; + `, +}); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx new file mode 100644 index 0000000..876e164 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; +import { RevertDashboardModal } from './RevertDashboardModal'; + +type VersionsTableProps = { + versions: DecoratedRevisionModel[]; + onCheck: (ev: React.FormEvent, versionId: number) => void; +}; +export const VersionHistoryTable: React.FC = ({ versions, onCheck }) => ( + + + + + + + + + + + + + {versions.map((version, idx) => ( + + + + + + + + + ))} + +
VersionDateUpdated byNotes
+ onCheck(ev, version.id)} + /> + {version.version}{version.createdDateString}{version.createdBy}{version.message} + {idx === 0 ? ( + + ) : ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+); diff --git a/public/app/features/dashboard/components/VersionHistory/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard/components/VersionHistory/__mocks__/dashboardHistoryMocks.ts new file mode 100644 index 0000000..0fcd3b4 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/__mocks__/dashboardHistoryMocks.ts @@ -0,0 +1,177 @@ +export function versions() { + return [ + { + id: 4, + dashboardId: 1, + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 3, + dashboardId: 1, + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 2, + dashboardId: 1, + parentVersion: 0, + restoredFrom: -1, + version: 2, + created: '2017-02-22T17:29:52-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 1, + dashboardId: 1, + parentVersion: 0, + restoredFrom: -1, + slug: 'history-dashboard', + version: 1, + created: '2017-02-22T17:06:37-08:00', + createdBy: 'admin', + message: '', + }, + ]; +} + +export function compare(type: any) { + return type === 'basic' ? '
' : '
'; +} + +export function restore(version: any, restoredFrom?: any): any { + return { + dashboard: { + meta: { + type: 'db', + canSave: true, + canEdit: true, + canStar: true, + slug: 'history-dashboard', + expires: '0001-01-01T00:00:00Z', + created: '2017-02-21T18:40:45-08:00', + updated: '2017-04-11T21:31:22.59219665-07:00', + updatedBy: 'admin', + createdBy: 'admin', + version: version, + }, + dashboard: { + annotations: { + list: [], + }, + description: 'A random dashboard for implementing the history list', + editable: true, + gnetId: null, + graphTooltip: 0, + hideControls: false, + id: 1, + links: [], + restoredFrom: restoredFrom, + rows: [ + { + collapse: false, + height: '250px', + panels: [ + { + aliasColors: {}, + bars: false, + datasource: null, + fill: 1, + id: 1, + legend: { + avg: false, + current: false, + max: false, + min: false, + show: true, + total: false, + values: false, + }, + lines: true, + linewidth: 1, + nullPointMode: 'null', + percentage: false, + pointradius: 5, + points: false, + renderer: 'flot', + seriesOverrides: [], + span: 12, + stack: false, + steppedLine: false, + targets: [{}], + thresholds: [], + timeFrom: null, + timeShift: null, + title: 'Panel Title', + tooltip: { + shared: true, + sort: 0, + value_type: 'individual', + }, + type: 'graph', + xaxis: { + mode: 'time', + name: null, + show: true, + values: [], + }, + yaxes: [ + { + format: 'short', + label: null, + logBase: 1, + max: null, + min: null, + show: true, + }, + { + format: 'short', + label: null, + logBase: 1, + max: null, + min: null, + show: true, + }, + ], + }, + ], + repeat: null, + repeatIteration: null, + repeatRowId: null, + showTitle: false, + title: 'Dashboard Row', + titleSize: 'h6', + }, + ], + schemaVersion: 14, + style: 'dark', + tags: ['development'], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: { + refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], + }, + timezone: 'utc', + title: 'History Dashboard', + version: version, + }, + }, + message: 'Dashboard restored to version ' + version, + version: version, + }; +} diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard/components/VersionHistory/index.ts new file mode 100644 index 0000000..c87d2d0 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/index.ts @@ -0,0 +1,5 @@ +export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv'; +export { VersionHistoryTable } from './VersionHistoryTable'; +export { VersionHistoryHeader } from './VersionHistoryHeader'; +export { VersionsHistoryButtons } from './VersionHistoryButtons'; +export { VersionHistoryComparison } from './VersionHistoryComparison'; diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx new file mode 100644 index 0000000..25f4eb3 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useAsyncFn } from 'react-use'; +import { AppEvents, locationUtil } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { StoreState } from 'app/types'; +import { historySrv } from './HistorySrv'; +import { DashboardModel } from '../../state'; +import { locationService } from '@grafana/runtime'; + +const restoreDashboard = async (version: number, dashboard: DashboardModel) => { + return await historySrv.restoreDashboard(dashboard, version); +}; + +export const useDashboardRestore = (version: number) => { + const dashboard = useSelector((state: StoreState) => state.dashboard.getModel()); + const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); + + useEffect(() => { + if (state.value) { + const location = locationService.getLocation(); + const newUrl = locationUtil.stripBaseFromUrl(state.value.url); + const prevState = (location.state as any)?.routeReloadCounter; + locationService.replace({ + ...location, + pathname: newUrl, + state: { routeReloadCounter: prevState ? prevState + 1 : 1 }, + }); + appEvents.emit(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]); + } + }, [state, version]); + return { state, onRestoreDashboard }; +}; diff --git a/public/app/features/dashboard/components/VersionHistory/utils.test.ts b/public/app/features/dashboard/components/VersionHistory/utils.test.ts new file mode 100644 index 0000000..a0eb097 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/utils.test.ts @@ -0,0 +1,291 @@ +import { getDiffText, getDiffOperationText, jsonDiff, Diff } from './utils'; + +describe('getDiffOperationText', () => { + const cases = [ + ['add', 'added'], + ['remove', 'deleted'], + ['replace', 'changed'], + ['byDefault', 'changed'], + ]; + + test.each(cases)('it returns the correct verb for an operation', (operation, expected) => { + expect(getDiffOperationText(operation)).toBe(expected); + }); +}); + +describe('getDiffText', () => { + const addEmptyArray = [{ op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 }, 'added list']; + const addArrayNumericProp = [ + { + op: 'add', + value: ['tag'], + path: ['panels', '3'], + }, + 'added item 3', + ]; + const addArrayProp = [ + { + op: 'add', + value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }], + path: ['panels', '3', 'targets'], + }, + 'added 2 targets', + ]; + const addValueNumericProp = [ + { + op: 'add', + value: 'foo', + path: ['panels', '3'], + }, + 'added item 3', + ]; + const addValueProp = [ + { + op: 'add', + value: 'foo', + path: ['panels', '3', 'targets'], + }, + 'added targets', + ]; + + const removeEmptyArray = [ + { op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 }, + 'deleted list', + ]; + const removeArrayNumericProp = [ + { + op: 'remove', + originalValue: ['tag'], + path: ['panels', '3'], + }, + 'deleted item 3', + ]; + const removeArrayProp = [ + { + op: 'remove', + originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }], + path: ['panels', '3', 'targets'], + }, + 'deleted 2 targets', + ]; + const removeValueNumericProp = [ + { + op: 'remove', + originalValue: 'foo', + path: ['panels', '3'], + }, + 'deleted item 3', + ]; + const removeValueProp = [ + { + op: 'remove', + originalValue: 'foo', + path: ['panels', '3', 'targets'], + }, + 'deleted targets', + ]; + const replaceValueNumericProp = [ + { + op: 'replace', + originalValue: 'foo', + value: 'bar', + path: ['panels', '3'], + }, + 'changed item 3', + ]; + const replaceValueProp = [ + { + op: 'replace', + originalValue: 'foo', + value: 'bar', + path: ['panels', '3', 'targets'], + }, + 'changed targets', + ]; + + const cases = [ + addEmptyArray, + addArrayNumericProp, + addArrayProp, + addValueNumericProp, + addValueProp, + removeEmptyArray, + removeArrayNumericProp, + removeArrayProp, + removeValueNumericProp, + removeValueProp, + replaceValueNumericProp, + replaceValueProp, + ]; + + test.each(cases)( + 'returns a semantic message based on the type of diff, the values and the location of the change', + (diff: Diff, expected: string) => { + expect(getDiffText(diff)).toBe(expected); + } + ); +}); + +describe('jsonDiff', () => { + it('returns data related to each change', () => { + const lhs = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + editable: true, + gnetId: null, + graphTooltip: 0, + id: 141, + links: [], + panels: [], + schemaVersion: 27, + style: 'dark', + tags: [], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'test dashboard', + uid: '_U4zObQMz', + version: 2, + }; + + const rhs = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + description: 'a description', + editable: true, + gnetId: null, + graphTooltip: 1, + id: 141, + links: [], + panels: [ + { + type: 'graph', + }, + ], + schemaVersion: 27, + style: 'dark', + tags: ['the tag'], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: { + refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], + }, + timezone: 'utc', + title: 'My favourite dashboard', + uid: '_U4zObQMz', + version: 3, + }; + + const expected = { + description: [ + { + op: 'add', + originalValue: undefined, + path: ['description'], + startLineNumber: 14, + value: 'a description', + }, + ], + graphTooltip: [ + { + op: 'replace', + originalValue: 0, + path: ['graphTooltip'], + startLineNumber: 17, + value: 1, + }, + ], + panels: [ + { + op: 'add', + originalValue: undefined, + path: ['panels', '0'], + startLineNumber: 21, + value: { + type: 'graph', + }, + }, + ], + tags: [ + { + op: 'add', + originalValue: undefined, + path: ['tags', '0'], + startLineNumber: 28, + value: 'the tag', + }, + ], + timepicker: [ + { + op: 'add', + originalValue: undefined, + path: ['timepicker', 'refresh_intervals'], + startLineNumber: 38, + value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], + }, + ], + timezone: [ + { + op: 'replace', + originalValue: '', + path: ['timezone'], + startLineNumber: 52, + value: 'utc', + }, + ], + title: [ + { + op: 'replace', + originalValue: 'test dashboard', + path: ['title'], + startLineNumber: 53, + value: 'My favourite dashboard', + }, + ], + version: [ + { + op: 'replace', + originalValue: 2, + path: ['version'], + startLineNumber: 55, + value: 3, + }, + ], + }; + + expect(jsonDiff(lhs, rhs)).toStrictEqual(expected); + }); +}); diff --git a/public/app/features/dashboard/components/VersionHistory/utils.ts b/public/app/features/dashboard/components/VersionHistory/utils.ts new file mode 100644 index 0000000..1dfeffd --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/utils.ts @@ -0,0 +1,100 @@ +import { compare, Operation } from 'fast-json-patch'; +// @ts-ignore +import jsonMap from 'json-source-map'; +import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash'; + +export type Diff = { + op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move'; + value: any; + originalValue: any; + path: string[]; + startLineNumber: number; +}; + +export type Diffs = { + [key: string]: Diff[]; +}; + +export const jsonDiff = (lhs: any, rhs: any): Diffs => { + const diffs = compare(lhs, rhs); + const lhsMap = jsonMap.stringify(lhs, null, 2); + const rhsMap = jsonMap.stringify(rhs, null, 2); + + const getDiffInformation = (diffs: Operation[]): Diff[] => { + return diffs.map((diff) => { + let originalValue = undefined; + let value = undefined; + let startLineNumber = 0; + + const path = tail(diff.path.split('/')); + + if (diff.op === 'replace') { + originalValue = get(lhs, path); + value = diff.value; + startLineNumber = rhsMap.pointers[diff.path].value.line; + } + if (diff.op === 'add') { + value = diff.value; + startLineNumber = rhsMap.pointers[diff.path].value.line; + } + if (diff.op === 'remove') { + originalValue = get(lhs, path); + startLineNumber = lhsMap.pointers[diff.path].value.line; + } + + return { + op: diff.op, + value, + path, + originalValue, + startLineNumber, + }; + }); + }; + + const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber'); + const groupByPath = (diffs: Diff[]) => + diffs.reduce>((acc, value) => { + const groupKey: string = value.path[0]; + if (!acc[groupKey]) { + acc[groupKey] = []; + } + acc[groupKey].push(value); + return acc; + }, {}); + + return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs); +}; + +export const getDiffText = (diff: Diff, showProp = true) => { + const prop = last(diff.path)!; + const propIsNumeric = isNumeric(prop); + const val = diff.op === 'remove' ? diff.originalValue : diff.value; + let text = getDiffOperationText(diff.op); + + if (showProp) { + if (propIsNumeric) { + text += ` item ${prop}`; + } else { + if (isArray(val) && !isEmpty(val)) { + text += ` ${val.length} ${prop}`; + } else { + text += ` ${prop}`; + } + } + } + + return text; +}; + +const isNumeric = (value: string) => !isNaN(toNumber(value)); + +export const getDiffOperationText = (operation: string): string => { + if (operation === 'add') { + return 'added'; + } + if (operation === 'remove') { + return 'deleted'; + } + return 'changed'; +}; diff --git a/public/app/features/dashboard/components/VizTypePicker/PanelTypeCard.tsx b/public/app/features/dashboard/components/VizTypePicker/PanelTypeCard.tsx new file mode 100644 index 0000000..9d3680b --- /dev/null +++ b/public/app/features/dashboard/components/VizTypePicker/PanelTypeCard.tsx @@ -0,0 +1,182 @@ +import React, { MouseEventHandler } from 'react'; +import { GrafanaTheme2, isUnsignedPluginSignature, PanelPluginMeta, PluginState } from '@grafana/data'; +import { Badge, BadgeProps, IconButton, PluginSignatureBadge, useStyles2 } from '@grafana/ui'; +import { css, cx } from '@emotion/css'; +import { selectors } from '@grafana/e2e-selectors'; + +interface Props { + isCurrent: boolean; + plugin: PanelPluginMeta; + title: string; + onClick: MouseEventHandler; + onDelete?: () => void; + disabled?: boolean; + showBadge?: boolean; + description?: string; +} + +export const PanelTypeCard: React.FC = ({ + isCurrent, + title, + plugin, + onClick, + onDelete, + disabled, + showBadge, + description, + children, +}) => { + const styles = useStyles2(getStyles); + const cssClass = cx({ + [styles.item]: true, + [styles.disabled]: disabled || plugin.state === PluginState.deprecated, + [styles.current]: isCurrent, + }); + + return ( +
+ + +
+
{title}
+ {description ? {description} : null} + {children} +
+ {showBadge && ( +
+ +
+ )} + {onDelete && ( + { + e.stopPropagation(); + onDelete(); + }} + aria-label="Delete button on panel type card" + /> + )} +
+ ); +}; + +PanelTypeCard.displayName = 'PanelTypeCard'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + item: css` + position: relative; + display: flex; + flex-shrink: 0; + cursor: pointer; + background: ${theme.colors.background.secondary}; + border-radius: ${theme.shape.borderRadius()}; + box-shadow: ${theme.shadows.z1}; + border: 1px solid ${theme.colors.background.secondary}; + align-items: center; + padding: 8px; + width: 100%; + position: relative; + overflow: hidden; + transition: ${theme.transitions.create(['background'], { + duration: theme.transitions.duration.short, + })}; + + &:hover { + background: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)}; + } + `, + itemContent: css` + position: relative; + width: 100%; + padding: ${theme.spacing(0, 1)}; + `, + current: css` + label: currentVisualizationItem; + background: ${theme.colors.action.selected}; + `, + disabled: css` + opacity: 0.2; + filter: grayscale(1); + cursor: default; + pointer-events: none; + `, + name: css` + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.fontWeightMedium}; + width: 100%; + `, + description: css` + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.bodySmall.fontSize}; + font-weight: ${theme.typography.fontWeightLight}; + width: 100%; + `, + img: css` + max-height: 38px; + width: 38px; + display: flex; + align-items: center; + `, + badge: css` + background: ${theme.colors.background.primary}; + `, + }; +}; + +interface PanelPluginBadgeProps { + plugin: PanelPluginMeta; +} + +const PanelPluginBadge: React.FC = ({ plugin }) => { + const display = getPanelStateBadgeDisplayModel(plugin); + + if (isUnsignedPluginSignature(plugin.signature)) { + return ; + } + + if (!display) { + return null; + } + + return ; +}; + +function getPanelStateBadgeDisplayModel(panel: PanelPluginMeta): BadgeProps | null { + switch (panel.state) { + case PluginState.deprecated: + return { + text: 'Deprecated', + color: 'red', + tooltip: `${panel.name} Panel is deprecated`, + }; + case PluginState.alpha: + return { + text: 'Alpha', + color: 'blue', + tooltip: `${panel.name} Panel is experimental`, + }; + case PluginState.beta: + return { + text: 'Beta', + color: 'blue', + tooltip: `${panel.name} Panel is in beta`, + }; + default: + return null; + } +} + +PanelPluginBadge.displayName = 'PanelPluginBadge'; diff --git a/public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx b/public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx new file mode 100644 index 0000000..a52ce80 --- /dev/null +++ b/public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useMemo } from 'react'; + +import config from 'app/core/config'; +import { VizTypePickerPlugin } from './VizTypePickerPlugin'; +import { EmptySearchResult, stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data'; +import { css } from '@emotion/css'; + +export interface Props { + current: PanelPluginMeta; + onTypeChange: (newType: PanelPluginMeta, withModKey?: boolean) => void; + searchQuery: string; + onClose: () => void; +} + +export function getAllPanelPluginMeta(): PanelPluginMeta[] { + const allPanels = config.panels; + + return Object.keys(allPanels) + .filter((key) => allPanels[key]['hideFromList'] === false) + .map((key) => allPanels[key]) + .sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort); +} + +export function filterPluginList( + pluginsList: PanelPluginMeta[], + searchQuery: string, + current: PanelPluginMeta +): PanelPluginMeta[] { + if (!searchQuery.length) { + return pluginsList.filter((p) => { + if (p.state === PluginState.deprecated) { + return current.id === p.id; + } + return true; + }); + } + + const query = searchQuery.toLowerCase(); + const first: PanelPluginMeta[] = []; + const match: PanelPluginMeta[] = []; + + for (const item of pluginsList) { + if (item.state === PluginState.deprecated && current.id !== item.id) { + continue; + } + + const name = item.name.toLowerCase(); + const idx = name.indexOf(query); + + if (idx === 0) { + first.push(item); + } else if (idx > 0) { + match.push(item); + } + } + + return first.concat(match); +} + +export const VizTypePicker: React.FC = ({ searchQuery, onTypeChange, current }) => { + const theme = useTheme(); + const styles = getStyles(theme); + const pluginsList: PanelPluginMeta[] = useMemo(() => { + return getAllPanelPluginMeta(); + }, []); + + const getFilteredPluginList = useCallback((): PanelPluginMeta[] => { + return filterPluginList(pluginsList, searchQuery, current); + }, [current, pluginsList, searchQuery]); + + const renderVizPlugin = (plugin: PanelPluginMeta, index: number) => { + const isCurrent = plugin.id === current.id; + const filteredPluginList = getFilteredPluginList(); + + const matchesQuery = filteredPluginList.indexOf(plugin) > -1; + return ( + onTypeChange(plugin, e.metaKey || e.ctrlKey || e.altKey)} + /> + ); + }; + + const filteredPluginList = getFilteredPluginList(); + const hasResults = filteredPluginList.length > 0; + const renderList = filteredPluginList.concat(pluginsList.filter((p) => filteredPluginList.indexOf(p) === -1)); + + return ( +
+ {hasResults ? ( + renderList.map((plugin, index) => { + if (plugin.state === PluginState.deprecated) { + return null; + } + return renderVizPlugin(plugin, index); + }) + ) : ( + Could not find anything matching your query + )} +
+ ); +}; + +VizTypePicker.displayName = 'VizTypePicker'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + grid: css` + max-width: 100%; + display: grid; + grid-gap: ${theme.spacing.sm}; + `, + }; +}); diff --git a/public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx b/public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx new file mode 100644 index 0000000..c39c958 --- /dev/null +++ b/public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx @@ -0,0 +1,26 @@ +import React, { MouseEventHandler } from 'react'; +import { PanelPluginMeta } from '@grafana/data'; +import { PanelTypeCard } from './PanelTypeCard'; + +interface Props { + isCurrent: boolean; + plugin: PanelPluginMeta; + onClick: MouseEventHandler; + disabled: boolean; +} + +export const VizTypePickerPlugin: React.FC = ({ isCurrent, plugin, onClick, disabled }) => { + return ( + + ); +}; + +VizTypePickerPlugin.displayName = 'VizTypePickerPlugin'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx new file mode 100644 index 0000000..cebc986 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -0,0 +1,317 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { UnthemedDashboardPage, mapStateToProps, Props, State } from './DashboardPage'; +import { DashboardModel } from '../state'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { DashboardInitPhase, DashboardRoutes } from 'app/types'; +import { notifyApp } from 'app/core/actions'; +import { cleanUpDashboardAndVariables } from '../state/actions'; +import { selectors } from '@grafana/e2e-selectors'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { createTheme } from '@grafana/data'; + +jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({})); + +interface ScenarioContext { + cleanUpDashboardAndVariablesMock: typeof cleanUpDashboardAndVariables; + dashboard?: DashboardModel | null; + setDashboardProp: (overrides?: any, metaOverrides?: any) => void; + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + cleanUpDashboardAndVariablesMock: jest.fn(), + setup: (fn) => { + setupFn = fn; + }, + setDashboardProp: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + ctx.wrapper?.setProps({ dashboard: ctx.dashboard }); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + ...getRouteComponentProps({ + match: { params: { slug: 'my-dash', uid: '11' } } as any, + route: { routeName: DashboardRoutes.Normal } as any, + }), + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + initDashboard: jest.fn(), + notifyApp: mockToolkitActionCreator(notifyApp), + cleanUpDashboardAndVariables: ctx.cleanUpDashboardAndVariablesMock, + cancelVariables: jest.fn(), + templateVarsChangedInUrl: jest.fn(), + dashboard: null, + theme: createTheme(), + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + ctx.wrapper = shallow(); + }, + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('DashboardPage', () => { + dashboardPageScenario('Given initial state', (ctx) => { + ctx.setup(() => { + ctx.mount(); + }); + + it('Should render nothing', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('Dashboard is fetching slowly', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.wrapper?.setProps({ + isInitSlow: true, + initPhase: DashboardInitPhase.Fetching, + }); + }); + + it('Should render slow init state', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('Dashboard init completed ', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + }); + + it('Should update title', () => { + expect(document.title).toBe('My dashboard - Grafana'); + }); + + it('Should render dashboard grid', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('When user goes into panel edit', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setProps({ + queryParams: { editPanel: '1' }, + }); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = ctx.wrapper?.state(); + expect(state).not.toBe(null); + expect(state?.editPanel).toBeDefined(); + }); + }); + + dashboardPageScenario('When user goes into panel edit but has no edit permissions', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({}, { canEdit: false }); + ctx.wrapper?.setProps({ + queryParams: { editPanel: '1' }, + }); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = ctx.wrapper?.state(); + expect(state?.editPanel).toBe(null); + }); + }); + dashboardPageScenario('When user goes back to dashboard from edit panel', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setState({ scrollTop: 100 }); + ctx.wrapper?.setProps({ + queryParams: { editPanel: '1' }, + }); + ctx.wrapper?.setProps({ + queryParams: {}, + }); + }); + + it('Should update model state normal state', () => { + expect(ctx.dashboard).toBeDefined(); + // @ts-ignore typescript doesn't understand that dashboard must be defined to reach the row below + expect(ctx.dashboard.panelInEdit).toBeUndefined(); + }); + + it('Should update component state to normal and restore scrollTop', () => { + const state = ctx.wrapper?.state(); + expect(ctx.wrapper).not.toBe(null); + expect(state).not.toBe(null); + expect(state?.editPanel).toBe(null); + expect(state?.scrollTop).toBe(100); + }); + }); + + dashboardPageScenario('When dashboard has editview url state', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setProps({ + queryParams: { editview: 'settings' }, + }); + }); + + it('should render settings view', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('When adding panel', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setState({ scrollTop: 100 }); + ctx.wrapper?.instance().onAddPanel(); + }); + + it('should set scrollTop to 0', () => { + expect(ctx.wrapper).not.toBe(null); + expect(ctx.wrapper?.state()).not.toBe(null); + expect(ctx.wrapper?.state().updateScrollTop).toBe(0); + }); + + it('should add panel widget to dashboard panels', () => { + expect(ctx.dashboard).not.toBe(null); + expect(ctx.dashboard?.panels[0].type).toBe('add-panel'); + }); + }); + + dashboardPageScenario('Given panel with id 0', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + ctx.wrapper?.setProps({ + queryParams: { editPanel: '0' }, + }); + }); + + it('Should go into edit mode', () => { + const state = ctx.wrapper?.state(); + expect(ctx.wrapper).not.toBe(null); + expect(state).not.toBe(null); + expect(state?.editPanel).not.toBe(null); + }); + }); + + dashboardPageScenario('When dashboard unmounts', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + ctx.wrapper?.unmount(); + }); + + it('Should call clean up action', () => { + expect(ctx.cleanUpDashboardAndVariablesMock).toHaveBeenCalledTimes(1); + }); + }); + + dashboardPageScenario('Kiosk mode none', (ctx) => { + ctx.setup(() => { + ctx.mount({ + queryParams: {}, + }); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + }); + + it('should not render dashboard navigation ', () => { + expect(ctx.wrapper?.find(`[aria-label="${selectors.pages.Dashboard.DashNav.nav}"]`)).toHaveLength(1); + expect(ctx.wrapper?.find(`[aria-label="${selectors.pages.Dashboard.SubMenu.submenu}"]`)).toHaveLength(1); + }); + }); + + dashboardPageScenario('Kiosk mode tv', (ctx) => { + ctx.setup(() => { + ctx.mount({ + queryParams: { kiosk: 'tv' }, + }); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + }); + + it('should not render dashboard navigation ', () => { + expect(ctx.wrapper?.find(`[aria-label="${selectors.pages.Dashboard.DashNav.nav}"]`)).toHaveLength(1); + expect(ctx.wrapper?.find(`[aria-label="${selectors.pages.Dashboard.SubMenu.submenu}"]`)).toHaveLength(0); + }); + }); + + dashboardPageScenario('Kiosk mode full', (ctx) => { + ctx.setup(() => { + ctx.mount({ + queryParams: { kiosk: true }, + }); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + }); + + it('should not render dashboard navigation and submenu', () => { + expect(ctx.wrapper?.find(`[aria-label="${selectors.pages.Dashboard.DashNav.nav}"]`)).toHaveLength(0); + expect(ctx.wrapper?.find(`[aria-label="${selectors.pages.Dashboard.SubMenu.submenu}"]`)).toHaveLength(0); + }); + }); + + describe('mapStateToProps', () => { + const props = mapStateToProps({ + panelEditor: {}, + dashboard: { + getModel: () => ({} as DashboardModel), + }, + } as any); + + expect(props.dashboard).toBeDefined(); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx new file mode 100644 index 0000000..cf71804 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -0,0 +1,404 @@ +import $ from 'jquery'; +import React, { MouseEvent, PureComponent } from 'react'; +import { css } from 'emotion'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { locationService } from '@grafana/runtime'; +import { selectors } from '@grafana/e2e-selectors'; +import { CustomScrollbar, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui'; + +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { Branding } from 'app/core/components/Branding/Branding'; +import { DashboardGrid } from '../dashgrid/DashboardGrid'; +import { DashNav } from '../components/DashNav'; +import { DashboardSettings } from '../components/DashboardSettings'; +import { PanelEditor } from '../components/PanelEditor/PanelEditor'; +import { initDashboard } from '../state/initDashboard'; +import { notifyApp } from 'app/core/actions'; +import { DashboardInitError, DashboardInitPhase, KioskMode, StoreState } from 'app/types'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { PanelInspector } from '../components/Inspector/PanelInspector'; +import { SubMenu } from '../components/SubMenu/SubMenu'; +import { cleanUpDashboardAndVariables } from '../state/actions'; +import { cancelVariables, templateVarsChangedInUrl } from '../../variables/state/actions'; +import { findTemplateVarChanges } from '../../variables/utils'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { getTimeSrv } from '../services/TimeSrv'; +import { getKioskMode } from 'app/core/navigation/kiosk'; +import { GrafanaTheme2, UrlQueryValue } from '@grafana/data'; +import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading'; +import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed'; + +export interface DashboardPageRouteParams { + uid?: string; + type?: string; + slug?: string; +} + +type DashboardPageRouteSearchParams = { + tab?: string; + folderId?: string; + editPanel?: string; + viewPanel?: string; + editview?: string; + inspect?: string; + kiosk?: UrlQueryValue; + from?: string; + to?: string; + refresh?: string; +}; + +export interface Props + extends Themeable2, + GrafanaRouteComponentProps { + initPhase: DashboardInitPhase; + isInitSlow: boolean; + dashboard: DashboardModel | null; + initError?: DashboardInitError; + initDashboard: typeof initDashboard; + cleanUpDashboardAndVariables: typeof cleanUpDashboardAndVariables; + notifyApp: typeof notifyApp; + isPanelEditorOpen?: boolean; + cancelVariables: typeof cancelVariables; + templateVarsChangedInUrl: typeof templateVarsChangedInUrl; +} + +export interface State { + editPanel: PanelModel | null; + viewPanel: PanelModel | null; + scrollTop: number; + updateScrollTop?: number; + rememberScrollTop: number; + showLoadingState: boolean; +} + +export class UnthemedDashboardPage extends PureComponent { + private forceRouteReloadCounter = 0; + state: State = this.getCleanState(); + + getCleanState(): State { + return { + editPanel: null, + viewPanel: null, + showLoadingState: false, + scrollTop: 0, + rememberScrollTop: 0, + }; + } + + componentDidMount() { + this.initDashboard(); + this.forceRouteReloadCounter = (this.props.history.location.state as any)?.routeReloadCounter || 0; + } + + componentWillUnmount() { + this.closeDashboard(); + } + + closeDashboard() { + this.props.cleanUpDashboardAndVariables(); + this.setPanelFullscreenClass(false); + this.setState(this.getCleanState()); + } + + initDashboard() { + const { dashboard, match, queryParams } = this.props; + + if (dashboard) { + this.closeDashboard(); + } + + this.props.initDashboard({ + urlSlug: match.params.slug, + urlUid: match.params.uid, + urlType: match.params.type, + urlFolderId: queryParams.folderId, + routeName: this.props.route.routeName, + fixUrl: true, + }); + } + + componentDidUpdate(prevProps: Props) { + const { dashboard, match, queryParams, templateVarsChangedInUrl } = this.props; + const { editPanel, viewPanel } = this.state; + + const routeReloadCounter = (this.props.history.location.state as any)?.routeReloadCounter; + + if (!dashboard) { + return; + } + + // if we just got dashboard update title + if (prevProps.dashboard !== dashboard) { + document.title = dashboard.title + ' - ' + Branding.AppTitle; + } + + if ( + prevProps.match.params.uid !== match.params.uid || + (routeReloadCounter !== undefined && this.forceRouteReloadCounter !== routeReloadCounter) + ) { + this.initDashboard(); + this.forceRouteReloadCounter = routeReloadCounter; + return; + } + + if (prevProps.location.search !== this.props.location.search) { + const prevUrlParams = prevProps.queryParams; + const urlParams = this.props.queryParams; + + if (urlParams?.from !== prevUrlParams?.from && urlParams?.to !== prevUrlParams?.to) { + getTimeSrv().updateTimeRangeFromUrl(); + } + + if (!prevUrlParams?.refresh && urlParams?.refresh) { + getTimeSrv().setAutoRefresh(urlParams.refresh); + } + + const templateVarChanges = findTemplateVarChanges(this.props.queryParams, prevProps.queryParams); + + if (templateVarChanges) { + templateVarsChangedInUrl(templateVarChanges); + } + } + + const urlEditPanelId = queryParams.editPanel; + const urlViewPanelId = queryParams.viewPanel; + + // entering edit mode + if (!editPanel && urlEditPanelId) { + dashboardWatcher.setEditingState(true); + + this.getPanelByIdFromUrlParam(urlEditPanelId, (panel) => { + // if no edit permission show error + if (!dashboard.canEditPanel(panel)) { + this.props.notifyApp(createErrorNotification('Permission to edit panel denied')); + return; + } + + this.setState({ editPanel: panel }); + }); + } + + // leaving edit mode + if (editPanel && !urlEditPanelId) { + dashboardWatcher.setEditingState(false); + this.setState({ editPanel: null }); + } + + // entering view mode + if (!viewPanel && urlViewPanelId) { + this.getPanelByIdFromUrlParam(urlViewPanelId, (panel) => { + this.setPanelFullscreenClass(true); + dashboard.initViewPanel(panel); + this.setState({ + viewPanel: panel, + rememberScrollTop: this.state.scrollTop, + updateScrollTop: 0, + }); + }); + } + + // leaving view mode + if (viewPanel && !urlViewPanelId) { + this.setPanelFullscreenClass(false); + dashboard.exitViewPanel(viewPanel); + this.setState( + { viewPanel: null, updateScrollTop: this.state.rememberScrollTop }, + this.triggerPanelsRendering.bind(this) + ); + } + } + + getPanelByIdFromUrlParam(urlPanelId: string, callback: (panel: PanelModel) => void) { + const { dashboard } = this.props; + + const panelId = parseInt(urlPanelId!, 10); + dashboard!.expandParentRowFor(panelId); + const panel = dashboard!.getPanelById(panelId); + + if (!panel) { + // Panel not found + this.props.notifyApp(createErrorNotification(`Panel with ID ${urlPanelId} not found`)); + // Clear url state + locationService.partial({ editPanel: null, viewPanel: null }); + return; + } + + callback(panel); + } + + triggerPanelsRendering() { + try { + this.props.dashboard!.render(); + } catch (err) { + console.error(err); + this.props.notifyApp(createErrorNotification(`Panel rendering error`, err)); + } + } + + setPanelFullscreenClass(isFullscreen: boolean) { + $('body').toggleClass('panel-in-fullscreen', isFullscreen); + } + + setScrollTop = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop, updateScrollTop: undefined }); + }; + + onAddPanel = () => { + const { dashboard } = this.props; + + if (!dashboard) { + return; + } + + // Return if the "Add panel" exists already + if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { + return; + } + + dashboard.addPanel({ + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + title: 'Panel Title', + }); + + // scroll to top after adding panel + this.setState({ updateScrollTop: 0 }); + }; + + getInspectPanel() { + const { dashboard, queryParams } = this.props; + + const inspectPanelId = queryParams.inspect; + + if (!dashboard || !inspectPanelId) { + return null; + } + + const inspectPanel = dashboard.getPanelById(parseInt(inspectPanelId, 10)); + + // cannot inspect panels plugin is not already loaded + if (!inspectPanel) { + return null; + } + + return inspectPanel; + } + + render() { + const { dashboard, isInitSlow, initError, isPanelEditorOpen, queryParams, theme } = this.props; + const { editPanel, viewPanel, scrollTop, updateScrollTop } = this.state; + const styles = getStyles(theme); + + if (!dashboard) { + if (isInitSlow) { + return ; + } + + return null; + } + + // Only trigger render when the scroll has moved by 25 + const approximateScrollTop = Math.round(scrollTop / 25) * 25; + const inspectPanel = this.getInspectPanel(); + const kioskMode = getKioskMode(queryParams.kiosk); + + return ( +
+ {kioskMode !== KioskMode.Full && ( +
+ +
+ )} + +
+ +
+ {initError && } + {!editPanel && kioskMode === KioskMode.Off && ( +
+ +
+ )} + + +
+
+
+ + {inspectPanel && } + {editPanel && } + {queryParams.editview && } +
+ ); + } +} + +export const mapStateToProps = (state: StoreState) => ({ + initPhase: state.dashboard.initPhase, + isInitSlow: state.dashboard.isInitSlow, + initError: state.dashboard.initError, + dashboard: state.dashboard.getModel(), + isPanelEditorOpen: state.panelEditor.isOpen, +}); + +const mapDispatchToProps = { + initDashboard, + cleanUpDashboardAndVariables, + notifyApp, + cancelVariables, + templateVarsChangedInUrl, +}; + +/* + * Styles + */ +export const getStyles = stylesFactory((theme: GrafanaTheme2) => { + return { + dashboardContainer: css` + position: absolute; + top: 0; + bottom: 0; + width: 100%; + height: 100%; + display: flex; + flex: 1 1 0; + flex-direction: column; + `, + dashboardScroll: css` + width: 100%; + flex-grow: 1; + min-height: 0; + display: flex; + `, + dashboardContent: css` + padding: ${theme.spacing(2)}; + flex-basis: 100%; + flex-grow: 1; + `, + }; +}); + +export const DashboardPage = withTheme2(UnthemedDashboardPage); +DashboardPage.displayName = 'DashboardPage'; +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx new file mode 100644 index 0000000..184f283 --- /dev/null +++ b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Props, SoloPanelPage } from './SoloPanelPage'; +import { Props as DashboardPanelProps } from '../dashgrid/DashboardPanel'; +import { DashboardModel } from '../state'; +import { DashboardRoutes } from 'app/types'; +import { getRouteComponentProps } from '../../../core/navigation/__mocks__/routeProps'; + +jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({})); +jest.mock('app/features/dashboard/dashgrid/DashboardPanel', () => { + class DashboardPanel extends React.Component { + render() { + // In this test we only check whether a new panel has arrived in the props + return <>{this.props.panel?.title}; + } + } + + return { DashboardPanel }; +}); + +interface ScenarioContext { + dashboard?: DashboardModel | null; + secondaryDashboard?: DashboardModel | null; + setDashboard: (overrides?: any, metaOverrides?: any) => void; + setSecondaryDashboard: (overrides?: any, metaOverrides?: any) => void; + mount: (propOverrides?: Partial) => void; + rerender: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function soloPanelPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + setup: (fn) => { + setupFn = fn; + }, + setDashboard: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + }, + setSecondaryDashboard: (overrides?: any, metaOverrides?: any) => { + ctx.secondaryDashboard = getTestDashboard(overrides, metaOverrides); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + ...getRouteComponentProps({ + match: { + params: { slug: 'my-dash', uid: '11' }, + } as any, + queryParams: { + panelId: '1', + }, + route: { routeName: DashboardRoutes.Normal } as any, + }), + initDashboard: jest.fn(), + dashboard: null, + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + let { rerender } = render(); + // prop updates will be submitted by rerendering the same component with different props + ctx.rerender = (newProps?: Partial) => { + Object.assign(props, newProps); + rerender(); + }; + }, + rerender: () => { + // will be replaced while mount() is called + }, + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('SoloPanelPage', () => { + soloPanelPageScenario('Given initial state', (ctx) => { + ctx.setup(() => { + ctx.mount(); + }); + + it('Should render nothing', () => { + expect(screen.queryByText(/Loading/)).not.toBeNull(); + }); + }); + + soloPanelPageScenario('Dashboard init completed ', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboard(); + expect(ctx.dashboard).not.toBeNull(); + // the componentDidMount will change the dashboard prop to the new dashboard + // emulate this by rerendering with new props + ctx.rerender({ dashboard: ctx.dashboard }); + }); + + it('Should render dashboard grid', async () => { + // check if the panel title has arrived in the DashboardPanel mock + expect(screen.queryByText(/My graph/)).not.toBeNull(); + }); + }); + + soloPanelPageScenario('When user navigates to other SoloPanelPage', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboard({ uid: 1, panels: [{ id: 1, type: 'graph', title: 'Panel 1' }] }); + ctx.setSecondaryDashboard({ uid: 2, panels: [{ id: 1, type: 'graph', title: 'Panel 2' }] }); + }); + + it('Should show other graph', () => { + // check that the title in the DashboardPanel has changed + ctx.rerender({ dashboard: ctx.dashboard }); + expect(screen.queryByText(/Panel 1/)).not.toBeNull(); + ctx.rerender({ dashboard: ctx.secondaryDashboard }); + expect(screen.queryByText(/Panel 1/)).toBeNull(); + expect(screen.queryByText(/Panel 2/)).not.toBeNull(); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx new file mode 100644 index 0000000..78046f9 --- /dev/null +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -0,0 +1,105 @@ +// Libraries +import React, { Component } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +// Components +import { DashboardPanel } from '../dashgrid/DashboardPanel'; +// Redux +import { initDashboard } from '../state/initDashboard'; +// Types +import { StoreState } from 'app/types'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +export interface DashboardPageRouteParams { + uid?: string; + type?: string; + slug?: string; +} + +export interface Props extends GrafanaRouteComponentProps { + initDashboard: typeof initDashboard; + dashboard: DashboardModel | null; +} + +export interface State { + panel: PanelModel | null; + notFound: boolean; +} + +export class SoloPanelPage extends Component { + state: State = { + panel: null, + notFound: false, + }; + + componentDidMount() { + const { match, route } = this.props; + + this.props.initDashboard({ + urlSlug: match.params.slug, + urlUid: match.params.uid, + urlType: match.params.type, + routeName: route.routeName, + fixUrl: false, + }); + } + + getPanelId(): number { + return parseInt(this.props.queryParams.panelId ?? '0', 10); + } + + componentDidUpdate(prevProps: Props) { + const { dashboard } = this.props; + + if (!dashboard) { + return; + } + + // we just got a new dashboard + if (!prevProps.dashboard || prevProps.dashboard.uid !== dashboard.uid) { + const panelId = this.getPanelId(); + + // need to expand parent row if this panel is inside a row + dashboard.expandParentRowFor(panelId); + + const panel = dashboard.getPanelById(panelId); + + if (!panel) { + this.setState({ notFound: true }); + return; + } + + this.setState({ panel }); + } + } + + render() { + const { dashboard } = this.props; + const { notFound, panel } = this.state; + + if (notFound) { + return
Panel with id {this.getPanelId()} not found
; + } + + if (!panel || !dashboard) { + return
Loading & initializing dashboard
; + } + + return ( +
+ +
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + dashboard: state.dashboard.getModel(), +}); + +const mapDispatchToProps = { + initDashboard, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage)); diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap new file mode 100644 index 0000000..77f9ce3 --- /dev/null +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -0,0 +1,852 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = ` +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+`; + +exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = ` + +`; + +exports[`DashboardPage Given initial state Should render nothing 1`] = `""`; + +exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = ` +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ +
+`; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx new file mode 100644 index 0000000..3b45858 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { DashboardGrid, Props } from './DashboardGrid'; +import { DashboardModel } from '../state'; + +interface ScenarioContext { + props: Props; + wrapper?: ShallowWrapper; + setup: (fn: () => void) => void; + setProps: (props: Partial) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 24, h: 10 }, + }, + { + id: 2, + type: 'graph2', + title: 'My graph2', + gridPos: { x: 0, y: 10, w: 25, h: 10 }, + }, + { + id: 3, + type: 'graph3', + title: 'My graph3', + gridPos: { x: 0, y: 20, w: 25, h: 100 }, + }, + { + id: 4, + type: 'graph4', + title: 'My graph4', + gridPos: { x: 0, y: 120, w: 25, h: 10 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function dashboardGridScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + setup: (fn) => { + setupFn = fn; + }, + props: { + editPanel: null, + viewPanel: null, + scrollTop: 0, + dashboard: getTestDashboard(), + }, + setProps: (props: Partial) => { + Object.assign(ctx.props, props); + if (ctx.wrapper) { + ctx.wrapper.setProps(ctx.props); + } + }, + }; + + beforeEach(() => { + setupFn(); + ctx.wrapper = shallow(); + }); + + scenarioFn(ctx); + }); +} + +describe('DashboardGrid', () => { + dashboardGridScenario('Can render dashboard grid', (ctx) => { + ctx.setup(() => {}); + + it('Should render', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx new file mode 100644 index 0000000..7727cd6 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -0,0 +1,281 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import ReactGridLayout, { ItemCallback } from 'react-grid-layout'; +import classNames from 'classnames'; +// @ts-ignore +import sizeMe from 'react-sizeme'; + +// Components +import { AddPanelWidget } from '../components/AddPanelWidget'; +import { DashboardRow } from '../components/DashboardRow'; + +// Types +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; +import { DashboardPanel } from './DashboardPanel'; +import { DashboardModel, PanelModel } from '../state'; +import { Subscription } from 'rxjs'; +import { DashboardPanelsChangedEvent } from 'app/types/events'; + +let lastGridWidth = 1200; +let ignoreNextWidthChange = false; + +interface GridWrapperProps { + size: { width: number }; + layout: ReactGridLayout.Layout[]; + onLayoutChange: (layout: ReactGridLayout.Layout[]) => void; + children: JSX.Element | JSX.Element[]; + onDragStop: ItemCallback; + onResize: ItemCallback; + onResizeStop: ItemCallback; + className: string; + isResizable?: boolean; + isDraggable?: boolean; + viewPanel: PanelModel | null; +} + +function GridWrapper({ + size, + layout, + onLayoutChange, + children, + onDragStop, + onResize, + onResizeStop, + className, + isResizable, + isDraggable, + viewPanel, +}: GridWrapperProps) { + const width = size.width > 0 ? size.width : lastGridWidth; + + // logic to ignore width changes (optimization) + if (width !== lastGridWidth) { + if (ignoreNextWidthChange) { + ignoreNextWidthChange = false; + } else if (!viewPanel && Math.abs(width - lastGridWidth) > 8) { + lastGridWidth = width; + } + } + + /* + Disable draggable if mobile device, solving an issue with unintentionally + moving panels. https://github.com/grafana/grafana/issues/18497 + theme.breakpoints.md = 769 + */ + const draggable = width <= 769 ? false : isDraggable; + + return ( + + {children} + + ); +} + +const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); + +export interface Props { + dashboard: DashboardModel; + editPanel: PanelModel | null; + viewPanel: PanelModel | null; + scrollTop: number; + isPanelEditorOpen?: boolean; +} + +export class DashboardGrid extends PureComponent { + private panelMap: { [id: string]: PanelModel } = {}; + private panelRef: { [id: string]: HTMLElement } = {}; + private eventSubs = new Subscription(); + + componentDidMount() { + const { dashboard } = this.props; + this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate)); + } + + componentWillUnmount() { + this.eventSubs.unsubscribe(); + } + + buildLayout() { + const layout = []; + this.panelMap = {}; + + for (const panel of this.props.dashboard.panels) { + const stringId = panel.id.toString(); + this.panelMap[stringId] = panel; + + if (!panel.gridPos) { + console.log('panel without gridpos'); + continue; + } + + const panelPos: any = { + i: stringId, + x: panel.gridPos.x, + y: panel.gridPos.y, + w: panel.gridPos.w, + h: panel.gridPos.h, + }; + + if (panel.type === 'row') { + panelPos.w = GRID_COLUMN_COUNT; + panelPos.h = 1; + panelPos.isResizable = false; + panelPos.isDraggable = panel.collapsed; + } + + layout.push(panelPos); + } + + return layout; + } + + onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => { + for (const newPos of newLayout) { + this.panelMap[newPos.i!].updateGridPos(newPos); + } + + this.props.dashboard.sortPanelsByGridPos(); + + // Call render() after any changes. This is called when the layout loads + this.forceUpdate(); + }; + + triggerForceUpdate = () => { + this.forceUpdate(); + }; + + updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => { + this.panelMap[item.i!].updateGridPos(item); + + // react-grid-layout has a bug (#670), and onLayoutChange() is only called when the component is mounted. + // So it's required to call it explicitly when panel resized or moved to save layout changes. + this.onLayoutChange(layout); + }; + + onResize: ItemCallback = (layout, oldItem, newItem) => { + this.panelMap[newItem.i!].updateGridPos(newItem); + }; + + onResizeStop: ItemCallback = (layout, oldItem, newItem) => { + this.updateGridPos(newItem, layout); + }; + + onDragStop: ItemCallback = (layout, oldItem, newItem) => { + this.updateGridPos(newItem, layout); + }; + + isInView = (panel: PanelModel): boolean => { + if (panel.isViewing || panel.isEditing) { + return true; + } + + // elem is set *after* the first render + const elem = this.panelRef[panel.id.toString()]; + if (!elem) { + // NOTE the gridPos is also not valid until after the first render + // since it is passed to the layout engine and made to be valid + // for example, you can have Y=0 for everything and it will stack them + // down vertically in the second call + return false; + } + + const top = elem.offsetTop; + const height = panel.gridPos.h * GRID_CELL_HEIGHT + 40; + const bottom = top + height; + + // Show things that are almost in the view + const buffer = 250; + + const viewTop = this.props.scrollTop; + if (viewTop > bottom + buffer) { + return false; // The panel is above the viewport + } + + // Use the whole browser height (larger than real value) + // TODO? is there a better way + const viewHeight = isNaN(window.innerHeight) ? (window as any).clientHeight : window.innerHeight; + const viewBot = viewTop + viewHeight; + if (top > viewBot + buffer) { + return false; + } + + return !this.props.dashboard.otherPanelInFullscreen(panel); + }; + + renderPanels() { + const panelElements = []; + + for (const panel of this.props.dashboard.panels) { + const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing }); + const id = panel.id.toString(); + panel.isInView = this.isInView(panel); + + panelElements.push( +
elem && (this.panelRef[id] = elem)}> + {this.renderPanel(panel)} +
+ ); + } + + return panelElements; + } + + renderPanel(panel: PanelModel) { + if (panel.type === 'row') { + return ; + } + + if (panel.type === 'add-panel') { + return ; + } + + return ( + + ); + } + + render() { + const { dashboard, viewPanel } = this.props; + + return ( + + {this.renderPanels()} + + ); + } +} + +export default hot(module)(DashboardGrid); diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx new file mode 100644 index 0000000..93e29b0 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -0,0 +1,151 @@ +// Libraries +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { connect, ConnectedProps } from 'react-redux'; + +// Components +import { PanelChrome } from './PanelChrome'; +import { PanelChromeAngular } from './PanelChromeAngular'; + +// Actions +import { initDashboardPanel } from '../state/actions'; + +// Types +import { DashboardModel, PanelModel } from '../state'; +import { StoreState } from 'app/types'; +import { PanelPlugin } from '@grafana/data'; +import { stylesFactory } from '@grafana/ui'; +import { css } from 'emotion'; + +export interface OwnProps { + panel: PanelModel; + dashboard: DashboardModel; + isEditing: boolean; + isViewing: boolean; + isInView: boolean; +} + +export interface State { + isLazy: boolean; +} + +const mapStateToProps = (state: StoreState, props: OwnProps) => { + const panelState = state.dashboard.panels[props.panel.id]; + if (!panelState) { + return { plugin: null }; + } + + return { + plugin: panelState.plugin, + }; +}; + +const mapDispatchToProps = { initDashboardPanel }; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export type Props = OwnProps & ConnectedProps; + +export class DashboardPanelUnconnected extends PureComponent { + specialPanels: { [key: string]: Function } = {}; + + constructor(props: Props) { + super(props); + + this.state = { + isLazy: !props.isInView, + }; + } + + componentDidMount() { + this.props.initDashboardPanel(this.props.panel); + } + + componentDidUpdate() { + if (this.state.isLazy && this.props.isInView) { + this.setState({ isLazy: false }); + } + } + + renderPanel(plugin: PanelPlugin) { + const { dashboard, panel, isViewing, isInView, isEditing } = this.props; + + return ( + + {({ width, height }) => { + if (width === 0) { + return null; + } + + if (plugin.angularPanelCtrl) { + return ( + + ); + } + + return ( + + ); + }} + + ); + } + + render() { + const { isViewing, plugin } = this.props; + const { isLazy } = this.state; + const styles = getStyles(); + + // If we have not loaded plugin exports yet, wait + if (!plugin) { + return null; + } + + // If we are lazy state don't render anything + if (isLazy) { + return null; + } + + return ( +
+ {this.renderPanel(plugin)} +
+ ); + } +} + +export const getStyles = stylesFactory(() => { + return { + panelWrapper: css` + height: 100%; + position: relative; + `, + panelWrapperView: css` + flex: 1 1 0; + height: 90%; + `, + }; +}); + +export const DashboardPanel = connector(DashboardPanelUnconnected); diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx new file mode 100644 index 0000000..03098c3 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -0,0 +1,92 @@ +import React, { FC } from 'react'; +import { ReplaySubject } from 'rxjs'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import { act, render, screen } from '@testing-library/react'; +import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin, PanelProps } from '@grafana/data'; + +import { PanelChrome, Props } from './PanelChrome'; +import { DashboardModel, PanelModel } from '../state'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import { setTimeSrv, TimeSrv } from '../services/TimeSrv'; + +jest.mock('app/core/profiler', () => ({ + profiler: { + renderingCompleted: jest.fn(), + }, +})); + +function setupTestContext(options: Partial) { + const mockStore = configureMockStore(); + const store = mockStore({ dashboard: { panels: [] } }); + const subject: ReplaySubject = new ReplaySubject(); + const panelQueryRunner = ({ + getData: () => subject, + run: () => { + subject.next({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }); + }, + } as unknown) as PanelQueryRunner; + const timeSrv = ({ + timeRange: jest.fn(), + } as unknown) as TimeSrv; + setTimeSrv(timeSrv); + const defaults: Props = { + panel: ({ + id: 123, + hasTitle: jest.fn(), + replaceVariables: jest.fn(), + events: { subscribe: jest.fn() }, + getQueryRunner: () => panelQueryRunner, + getOptions: jest.fn(), + getDisplayTitle: jest.fn(), + } as unknown) as PanelModel, + dashboard: ({ + panelInitialized: jest.fn(), + getTimezone: () => 'browser', + } as unknown) as DashboardModel, + plugin: ({ + meta: { skipDataQuery: false }, + panel: TestPanelComponent, + } as unknown) as PanelPlugin, + isViewing: true, + isEditing: false, + isInView: false, + width: 100, + height: 100, + }; + + const props = { ...defaults, ...options }; + const { rerender } = render( + + + + ); + + return { rerender, props, subject, store }; +} + +describe('PanelChrome', () => { + describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => { + it('then it should load the panel successfully when scrolled into view again', () => { + const { rerender, props, subject, store } = setupTestContext({}); + + expect(screen.queryByText(/plugin panel to render/i)).not.toBeInTheDocument(); + + act(() => { + subject.next({ state: LoadingState.Loading, series: [], timeRange: getDefaultTimeRange() }); + subject.next({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }); + }); + + const newProps = { ...props, isInView: true }; + rerender( + + + + ); + + expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument(); + }); + }); +}); + +const TestPanelComponent: FC = () =>
Plugin Panel to Render
; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx new file mode 100644 index 0000000..d7d6433 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -0,0 +1,435 @@ +// Libraries +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { Subscription } from 'rxjs'; +// Components +import { PanelHeader } from './PanelHeader/PanelHeader'; +import { ErrorBoundary, PanelContextProvider, PanelContext, SeriesVisibilityChangeMode } from '@grafana/ui'; +// Utils & Services +import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; +import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; +import { profiler } from 'app/core/profiler'; +import config from 'app/core/config'; +// Types +import { DashboardModel, PanelModel } from '../state'; +import { PANEL_BORDER } from 'app/core/constants'; +import { + AbsoluteTimeRange, + DashboardCursorSync, + EventBusSrv, + EventFilterOptions, + FieldConfigSource, + getDefaultTimeRange, + LoadingState, + PanelData, + PanelPlugin, + PanelPluginMeta, + toDataFrameDTO, + toUtc, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { loadSnapshotData } from '../utils/loadSnapshotData'; +import { RefreshEvent, RenderEvent } from 'app/types/events'; +import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory'; +import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; + +const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; + +export interface Props { + panel: PanelModel; + dashboard: DashboardModel; + plugin: PanelPlugin; + isViewing: boolean; + isEditing: boolean; + isInView: boolean; + width: number; + height: number; +} + +export interface State { + isFirstLoad: boolean; + renderCounter: number; + errorMessage?: string; + refreshWhenInView: boolean; + context: PanelContext; + data: PanelData; +} + +export class PanelChrome extends Component { + private readonly timeSrv: TimeSrv = getTimeSrv(); + private subs = new Subscription(); + private eventFilter: EventFilterOptions = { onlyLocal: true }; + + constructor(props: Props) { + super(props); + + // Can this eventBus be on PanelModel? + // when we have more complex event filtering, that may be a better option + const eventBus = props.dashboard.events + ? props.dashboard.events.newScopedBus( + `panel:${props.panel.id}`, // panelID + this.eventFilter + ) + : new EventBusSrv(); + + this.state = { + isFirstLoad: true, + renderCounter: 0, + refreshWhenInView: false, + context: { + sync: props.isEditing ? DashboardCursorSync.Off : props.dashboard.graphTooltip, + eventBus, + onSeriesColorChange: this.onSeriesColorChange, + onToggleSeriesVisibility: this.onSeriesVisibilityChange, + }, + data: this.getInitialPanelDataState(), + }; + } + + onSeriesColorChange = (label: string, color: string) => { + this.onFieldConfigChange(changeSeriesColorConfigFactory(label, color, this.props.panel.fieldConfig)); + }; + + onSeriesVisibilityChange = (label: string, mode: SeriesVisibilityChangeMode) => { + this.onFieldConfigChange( + seriesVisibilityConfigFactory(label, mode, this.props.panel.fieldConfig, this.state.data.series) + ); + }; + + getInitialPanelDataState(): PanelData { + return { + state: LoadingState.NotStarted, + series: [], + timeRange: getDefaultTimeRange(), + }; + } + + componentDidMount() { + const { panel, dashboard } = this.props; + + // Subscribe to panel events + this.subs.add(panel.events.subscribe(RefreshEvent, this.onRefresh)); + this.subs.add(panel.events.subscribe(RenderEvent, this.onRender)); + + dashboard.panelInitialized(this.props.panel); + + // Move snapshot data into the query response + if (this.hasPanelSnapshot) { + this.setState({ + data: loadSnapshotData(panel, dashboard), + isFirstLoad: false, + }); + return; + } + + if (!this.wantsQueryExecution) { + this.setState({ isFirstLoad: false }); + } + + this.subs.add( + panel + .getQueryRunner() + .getData({ withTransforms: true, withFieldConfig: true }) + .subscribe({ + next: (data) => this.onDataUpdate(data), + }) + ); + } + + componentWillUnmount() { + this.subs.unsubscribe(); + } + + componentDidUpdate(prevProps: Props) { + const { isInView, isEditing } = this.props; + + if (prevProps.dashboard.graphTooltip !== this.props.dashboard.graphTooltip) { + this.setState((s) => { + return { + context: { ...s.context, sync: isEditing ? DashboardCursorSync.Off : this.props.dashboard.graphTooltip }, + }; + }); + } + + if (isEditing !== prevProps.isEditing) { + this.setState((s) => { + return { + context: { ...s.context, sync: isEditing ? DashboardCursorSync.Off : this.props.dashboard.graphTooltip }, + }; + }); + } + + // View state has changed + if (isInView !== prevProps.isInView) { + if (isInView) { + // Check if we need a delayed refresh + if (this.state.refreshWhenInView) { + this.onRefresh(); + } + } + } + } + + shouldComponentUpdate(prevProps: Props, prevState: State) { + const { plugin, panel } = this.props; + + // If plugin changed we need to process fieldOverrides again + // We do this by asking panel query runner to resend last result + if (prevProps.plugin !== plugin) { + panel.getQueryRunner().resendLastResult(); + return false; + } + + return true; + } + + // Updates the response with information from the stream + // The next is outside a react synthetic event so setState is not batched + // So in this context we can only do a single call to setState + onDataUpdate(data: PanelData) { + const { isInView, dashboard, panel, plugin } = this.props; + + if (!isInView) { + if (data.state !== LoadingState.Streaming) { + // Ignore events when not visible. + // The call will be repeated when the panel comes into view + this.setState({ refreshWhenInView: true }); + } + return; + } + + // Ignore this data update if we are now a non data panel + if (plugin.meta.skipDataQuery) { + this.setState({ data: this.getInitialPanelDataState() }); + return; + } + + let { isFirstLoad } = this.state; + let errorMessage: string | undefined; + + switch (data.state) { + case LoadingState.Loading: + // Skip updating state data if it is already in loading state + // This is to avoid rendering partial loading responses + if (this.state.data.state === LoadingState.Loading) { + return; + } + break; + case LoadingState.Error: + const { error } = data; + if (error) { + if (errorMessage !== error.message) { + errorMessage = error.message; + } + } + break; + case LoadingState.Done: + // If we are doing a snapshot save data in panel model + if (dashboard.snapshot) { + panel.snapshotData = data.series.map((frame) => toDataFrameDTO(frame)); + } + if (isFirstLoad) { + isFirstLoad = false; + } + break; + } + + this.setState({ isFirstLoad, errorMessage, data }); + } + + onRefresh = () => { + const { panel, isInView, width } = this.props; + if (!isInView) { + this.setState({ refreshWhenInView: true }); + return; + } + + const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange()); + + // Issue Query + if (this.wantsQueryExecution) { + if (width < 0) { + return; + } + + panel.getQueryRunner().run({ + datasource: panel.datasource, + queries: panel.targets, + panelId: panel.editSourceId || panel.id, + dashboardId: this.props.dashboard.id, + timezone: this.props.dashboard.getTimezone(), + timeRange: timeData.timeRange, + timeInfo: timeData.timeInfo, + maxDataPoints: panel.maxDataPoints || width, + minInterval: panel.interval, + scopedVars: panel.scopedVars, + cacheTimeout: panel.cacheTimeout, + transformations: panel.transformations, + }); + } else { + // The panel should render on refresh as well if it doesn't have a query, like clock panel + this.setState((prevState) => ({ + data: { ...prevState.data, timeRange: this.timeSrv.timeRange() }, + })); + } + }; + + onRender = () => { + const stateUpdate = { renderCounter: this.state.renderCounter + 1 }; + this.setState(stateUpdate); + }; + + onOptionsChange = (options: any) => { + this.props.panel.updateOptions(options); + }; + + onFieldConfigChange = (config: FieldConfigSource) => { + this.props.panel.updateFieldConfig(config); + }; + + onPanelError = (message: string) => { + if (this.state.errorMessage !== message) { + this.setState({ errorMessage: message }); + } + }; + + get hasPanelSnapshot() { + const { panel } = this.props; + return panel.snapshotData && panel.snapshotData.length; + } + + get wantsQueryExecution() { + return !(this.props.plugin.meta.skipDataQuery || this.hasPanelSnapshot); + } + + onChangeTimeRange = (timeRange: AbsoluteTimeRange) => { + this.timeSrv.setTime({ + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }); + }; + + shouldSignalRenderingCompleted(loadingState: LoadingState, pluginMeta: PanelPluginMeta) { + return loadingState === LoadingState.Done || pluginMeta.skipDataQuery; + } + + renderPanel(width: number, height: number) { + const { panel, plugin, dashboard } = this.props; + const { renderCounter, data, isFirstLoad } = this.state; + const { theme } = config; + const { state: loadingState } = data; + + // do not render component until we have first data + if (isFirstLoad && (loadingState === LoadingState.Loading || loadingState === LoadingState.NotStarted)) { + return null; + } + + // This is only done to increase a counter that is used by backend + // image rendering to know when to capture image + if (this.shouldSignalRenderingCompleted(loadingState, plugin.meta)) { + profiler.renderingCompleted(); + } + + const PanelComponent = plugin.panel!; + const timeRange = data.timeRange || this.timeSrv.timeRange(); + const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight; + const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; + const panelWidth = width - chromePadding * 2 - PANEL_BORDER; + const innerPanelHeight = height - headerHeight - chromePadding * 2 - PANEL_BORDER; + const panelContentClassNames = classNames({ + 'panel-content': true, + 'panel-content--no-padding': plugin.noPadding, + }); + const panelOptions = panel.getOptions(); + + // Update the event filter (dashboard settings may have changed) + // Yes this is called ever render for a function that is triggered on every mouse move + this.eventFilter.onlyLocal = dashboard.graphTooltip === 0; + + return ( + <> +
+ + + +
+ + ); + } + + hasOverlayHeader() { + const { panel } = this.props; + const { errorMessage, data } = this.state; + + // always show normal header if we have an error message + if (errorMessage) { + return false; + } + + // always show normal header if we have time override + if (data.request && data.request.timeInfo) { + return false; + } + + return !panel.hasTitle(); + } + + render() { + const { dashboard, panel, isViewing, isEditing, width, height } = this.props; + const { errorMessage, data } = this.state; + const { transparent } = panel; + + let alertState = data.alertState?.state; + + const containerClassNames = classNames({ + 'panel-container': true, + 'panel-container--absolute': true, + 'panel-container--transparent': transparent, + 'panel-container--no-title': this.hasOverlayHeader(), + [`panel-alert-state--${alertState}`]: alertState !== undefined, + }); + + return ( +
+ + + {({ error }) => { + if (error) { + this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR); + return null; + } + return this.renderPanel(width, height); + }} + +
+ ); + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx new file mode 100644 index 0000000..08bac0b --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -0,0 +1,242 @@ +// Libraries +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { Subscription } from 'rxjs'; +import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; +// Components +import { PanelHeader } from './PanelHeader/PanelHeader'; +// Utils & Services +import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; +import { AngularComponent, getAngularLoader } from '@grafana/runtime'; +import { setPanelAngularComponent } from '../state/reducers'; +import config from 'app/core/config'; +// Types +import { DashboardModel, PanelModel } from '../state'; +import { StoreState } from 'app/types'; +import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data'; +import { PANEL_BORDER } from 'app/core/constants'; +import { selectors } from '@grafana/e2e-selectors'; + +interface OwnProps { + panel: PanelModel; + dashboard: DashboardModel; + plugin: PanelPlugin; + isViewing: boolean; + isEditing: boolean; + isInView: boolean; + width: number; + height: number; +} + +interface ConnectedProps { + angularComponent?: AngularComponent | null; +} + +interface DispatchProps { + setPanelAngularComponent: typeof setPanelAngularComponent; +} + +export type Props = OwnProps & ConnectedProps & DispatchProps; + +export interface State { + data: PanelData; + errorMessage?: string; +} + +interface AngularScopeProps { + panel: PanelModel; + dashboard: DashboardModel; + size: { + height: number; + width: number; + }; +} + +export class PanelChromeAngularUnconnected extends PureComponent { + element: HTMLElement | null = null; + timeSrv: TimeSrv = getTimeSrv(); + scopeProps?: AngularScopeProps; + subs = new Subscription(); + + constructor(props: Props) { + super(props); + this.state = { + data: { + state: LoadingState.NotStarted, + series: [], + timeRange: getDefaultTimeRange(), + }, + }; + } + + componentDidMount() { + const { panel } = this.props; + this.loadAngularPanel(); + + // subscribe to data events + const queryRunner = panel.getQueryRunner(); + + // we are not displaying any of this data so no need for transforms or field config + this.subs.add( + queryRunner.getData({ withTransforms: false, withFieldConfig: false }).subscribe({ + next: (data: PanelData) => this.onPanelDataUpdate(data), + }) + ); + } + + onPanelDataUpdate(data: PanelData) { + let errorMessage: string | undefined; + + if (data.state === LoadingState.Error) { + const { error } = data; + if (error) { + if (errorMessage !== error.message) { + errorMessage = error.message; + } + } + } + + this.setState({ data, errorMessage }); + } + + componentWillUnmount() { + this.cleanUpAngularPanel(); + this.subs.unsubscribe(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { plugin, height, width, panel } = this.props; + + if (prevProps.plugin !== plugin) { + this.cleanUpAngularPanel(); + this.loadAngularPanel(); + } + + if (prevProps.width !== width || prevProps.height !== height) { + if (this.scopeProps) { + this.scopeProps.size.height = this.getInnerPanelHeight(); + this.scopeProps.size.width = this.getInnerPanelWidth(); + panel.render(); + } + } + } + + getInnerPanelHeight() { + const { plugin, height } = this.props; + const { theme } = config; + + const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight; + const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; + return height - headerHeight - chromePadding * 2 - PANEL_BORDER; + } + + getInnerPanelWidth() { + const { plugin, width } = this.props; + const { theme } = config; + + const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; + return width - chromePadding * 2 - PANEL_BORDER; + } + + loadAngularPanel() { + const { panel, dashboard, setPanelAngularComponent } = this.props; + + // if we have no element or already have loaded the panel return + if (!this.element) { + return; + } + + const loader = getAngularLoader(); + const template = ''; + + this.scopeProps = { + panel: panel, + dashboard: dashboard, + size: { width: this.getInnerPanelWidth(), height: this.getInnerPanelHeight() }, + }; + + setPanelAngularComponent({ + panelId: panel.id, + angularComponent: loader.load(this.element, this.scopeProps, template), + }); + } + + cleanUpAngularPanel() { + const { angularComponent, setPanelAngularComponent, panel } = this.props; + + if (angularComponent) { + angularComponent.destroy(); + } + + setPanelAngularComponent({ panelId: panel.id, angularComponent: null }); + } + + hasOverlayHeader() { + const { panel } = this.props; + const { errorMessage, data } = this.state; + + // always show normal header if we have an error message + if (errorMessage) { + return false; + } + + // always show normal header if we have time override + if (data.request && data.request.timeInfo) { + return false; + } + + return !panel.hasTitle(); + } + + render() { + const { dashboard, panel, isViewing, isEditing, plugin } = this.props; + const { errorMessage, data } = this.state; + const { transparent } = panel; + + let alertState = data.alertState?.state; + + const containerClassNames = classNames({ + 'panel-container': true, + 'panel-container--absolute': true, + 'panel-container--transparent': transparent, + 'panel-container--no-title': this.hasOverlayHeader(), + 'panel-has-alert': panel.alert !== undefined, + [`panel-alert-state--${alertState}`]: alertState !== undefined, + }); + + const panelContentClassNames = classNames({ + 'panel-content': true, + 'panel-content--no-padding': plugin.noPadding, + }); + + return ( +
+ +
+
(this.element = element)} className="panel-height-helper" /> +
+
+ ); + } +} + +const mapStateToProps: MapStateToProps = (state, props) => { + return { + angularComponent: state.dashboard.panels[props.panel.id].angularComponent, + }; +}; + +const mapDispatchToProps: MapDispatchToProps = { setPanelAngularComponent }; + +export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx new file mode 100644 index 0000000..775c103 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react'; +import { cx } from '@emotion/css'; +import { DataLink, PanelData } from '@grafana/data'; +import { Icon } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +import PanelHeaderCorner from './PanelHeaderCorner'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; +import { PanelHeaderNotices } from './PanelHeaderNotices'; +import { PanelHeaderMenuTrigger } from './PanelHeaderMenuTrigger'; +import { PanelHeaderLoadingIndicator } from './PanelHeaderLoadingIndicator'; +import { PanelHeaderMenuWrapper } from './PanelHeaderMenuWrapper'; + +export interface Props { + panel: PanelModel; + dashboard: DashboardModel; + title?: string; + description?: string; + links?: DataLink[]; + error?: string; + alertState?: string; + isViewing: boolean; + isEditing: boolean; + data: PanelData; +} + +export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => { + const onCancelQuery = () => panel.getQueryRunner().cancelQuery(); + const title = panel.getDisplayTitle(); + const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : ''); + + return ( + <> + +
+ + + {({ closeMenu, panelMenuOpen }) => { + return ( +
+ + {panel.libraryPanel && } + {alertState ? ( + + ) : null} + {title} + + + {data.request && data.request.timeInfo && ( + + {data.request.timeInfo} + + )} +
+ ); + }} +
+
+ + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx new file mode 100644 index 0000000..9d2095e --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { PanelHeaderCorner } from './PanelHeaderCorner'; +import { PanelModel } from '../../state'; + +describe('Render', () => { + it('should render component', () => { + const panel = new PanelModel({}); + const wrapper = shallow(); + const instance = wrapper.instance() as PanelHeaderCorner; + + expect(instance.getInfoContent()).toBeDefined(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx new file mode 100644 index 0000000..6e70c6c --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -0,0 +1,115 @@ +import React, { Component } from 'react'; + +import { renderMarkdown, LinkModelSupplier, ScopedVars } from '@grafana/data'; +import { Tooltip, PopoverContent } from '@grafana/ui'; +import { getLocationSrv, getTemplateSrv } from '@grafana/runtime'; + +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { InspectTab } from 'app/features/inspector/types'; +import { selectors } from '@grafana/e2e-selectors'; + +enum InfoMode { + Error = 'Error', + Info = 'Info', + Links = 'Links', +} + +interface Props { + panel: PanelModel; + title?: string; + description?: string; + scopedVars?: ScopedVars; + links?: LinkModelSupplier; + error?: string; +} + +export class PanelHeaderCorner extends Component { + timeSrv: TimeSrv = getTimeSrv(); + + getInfoMode = () => { + const { panel, error } = this.props; + if (error) { + return InfoMode.Error; + } + if (!!panel.description) { + return InfoMode.Info; + } + if (panel.links && panel.links.length) { + return InfoMode.Links; + } + + return undefined; + }; + + getInfoContent = (): JSX.Element => { + const { panel } = this.props; + const markdown = panel.description || ''; + const interpolatedMarkdown = getTemplateSrv().replace(markdown, panel.scopedVars); + const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown); + const links = this.props.links && this.props.links.getLinks(panel.replaceVariables); + + return ( +
+
+ + {links && links.length > 0 && ( + + )} +
+ ); + }; + + /** + * Open the Panel Inspector when we click on an error + */ + onClickError = () => { + getLocationSrv().update({ partial: true, query: { inspect: this.props.panel.id, inspectTab: InspectTab.Error } }); + }; + + renderCornerType(infoMode: InfoMode, content: PopoverContent, onClick?: () => void) { + const theme = infoMode === InfoMode.Error ? 'error' : 'info'; + const className = `panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`; + const ariaLabel = selectors.components.Panels.Panel.headerCornerInfo(infoMode.toLowerCase()); + + return ( + +
+ + +
+
+ ); + } + + render() { + const { error } = this.props; + const infoMode: InfoMode | undefined = this.getInfoMode(); + + if (!infoMode) { + return null; + } + + if (infoMode === InfoMode.Error && error) { + return this.renderCornerType(infoMode, error, this.onClickError); + } + + if (infoMode === InfoMode.Info || infoMode === InfoMode.Links) { + return this.renderCornerType(infoMode, this.getInfoContent); + } + + return null; + } +} + +export default PanelHeaderCorner; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx new file mode 100644 index 0000000..fb1d42a --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme, LoadingState } from '@grafana/data'; +import { Icon, Tooltip, useStyles } from '@grafana/ui'; + +interface Props { + state: LoadingState; + onClick: () => void; +} + +export const PanelHeaderLoadingIndicator: FC = ({ state, onClick }) => { + const styles = useStyles(getStyles); + + if (state === LoadingState.Loading) { + return ( +
+ + + +
+ ); + } + + if (state === LoadingState.Streaming) { + return ( +
+
+
+ ); + } + + return null; +}; + +function getStyles(theme: GrafanaTheme) { + return { + streamIndicator: css` + width: 10px; + height: 10px; + background: ${theme.colors.textFaint}; + box-shadow: 0 0 2px ${theme.colors.textFaint}; + border-radius: 50%; + position: relative; + top: 6px; + right: 1px; + `, + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx new file mode 100644 index 0000000..c180359 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx @@ -0,0 +1,34 @@ +import React, { PureComponent } from 'react'; +import { PanelHeaderMenuItem } from './PanelHeaderMenuItem'; +import { PanelMenuItem } from '@grafana/data'; + +export interface Props { + items: PanelMenuItem[]; +} + +export class PanelHeaderMenu extends PureComponent { + renderItems = (menu: PanelMenuItem[], isSubMenu = false) => { + return ( +
    + {menu.map((menuItem, idx: number) => { + return ( + + {menuItem.subMenu && this.renderItems(menuItem.subMenu, true)} + + ); + })} +
+ ); + }; + + render() { + return
{this.renderItems(this.props.items)}
; + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx new file mode 100644 index 0000000..ab1a539 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx @@ -0,0 +1,67 @@ +import React, { FC, useState } from 'react'; +import { css } from '@emotion/css'; +import { PanelMenuItem } from '@grafana/data'; +import { Icon, IconName, useTheme } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +interface Props { + children?: any; +} + +export const PanelHeaderMenuItem: FC = (props) => { + const [ref, setRef] = useState(null); + const isSubMenu = props.type === 'submenu'; + const isDivider = props.type === 'divider'; + const theme = useTheme(); + const menuIconClassName = css` + margin-right: ${theme.spacing.sm}; + a::after { + display: none; + } + `; + const shortcutIconClassName = css` + position: absolute; + top: 7px; + right: ${theme.spacing.xs}; + color: ${theme.colors.textWeak}; + `; + + return isDivider ? ( +
  • + ) : ( +
  • + + {props.iconClassName && } + + {props.text} + {isSubMenu && } + + {props.shortcut && ( + + {props.shortcut} + + )} + + {props.children} +
  • + ); +}; + +function getDropdownLocationCssClass(element: HTMLElement | null) { + if (!element) { + return 'invisible'; + } + + const wrapperPos = element.parentElement!.getBoundingClientRect(); + const pos = element.getBoundingClientRect(); + + if (pos.width === 0) { + return 'invisible'; + } + + if (wrapperPos.right + pos.width + 10 > window.innerWidth) { + return 'pull-left'; + } else { + return 'pull-right'; + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx new file mode 100644 index 0000000..4fd2950 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx @@ -0,0 +1,29 @@ +import { FC, ReactElement, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { PanelMenuItem } from '@grafana/data'; + +import { DashboardModel, PanelModel } from '../../state'; +import { StoreState } from '../../../../types'; +import { getPanelMenu } from '../../utils/getPanelMenu'; + +interface PanelHeaderMenuProviderApi { + items: PanelMenuItem[]; +} + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + children: (props: PanelHeaderMenuProviderApi) => ReactElement; +} + +export const PanelHeaderMenuProvider: FC = ({ panel, dashboard, children }) => { + const [items, setItems] = useState([]); + const angularComponent = useSelector( + (state: StoreState) => state.dashboard.panels[panel.id]?.angularComponent || null + ); + useEffect(() => { + setItems(getPanelMenu(dashboard, panel, angularComponent)); + }, [dashboard, panel, angularComponent, setItems]); + + return children({ items }); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx new file mode 100644 index 0000000..3874129 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx @@ -0,0 +1,51 @@ +import React, { FC, HTMLAttributes, MouseEvent, ReactElement, useCallback, useState } from 'react'; +import { CartesianCoords2D } from '@grafana/data'; + +interface PanelHeaderMenuTriggerApi { + panelMenuOpen: boolean; + closeMenu: () => void; +} + +interface Props extends HTMLAttributes { + children: (props: PanelHeaderMenuTriggerApi) => ReactElement; +} + +export const PanelHeaderMenuTrigger: FC = ({ children, ...divProps }) => { + const [clickCoordinates, setClickCoordinates] = useState({ x: 0, y: 0 }); + const [panelMenuOpen, setPanelMenuOpen] = useState(false); + const onMenuToggle = useCallback( + (event: MouseEvent) => { + if (!isClick(clickCoordinates, eventToClickCoordinates(event))) { + return; + } + + event.stopPropagation(); + + setPanelMenuOpen(!panelMenuOpen); + }, + [clickCoordinates, panelMenuOpen, setPanelMenuOpen] + ); + const onMouseDown = useCallback( + (event: MouseEvent) => { + setClickCoordinates(eventToClickCoordinates(event)); + }, + [setClickCoordinates] + ); + + return ( +
    + {children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })} +
    + ); +}; + +function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D): boolean { + return clicked.x === current.x && clicked.y === current.y; +} + +function eventToClickCoordinates(event: MouseEvent): CartesianCoords2D { + return { + x: Math.floor(event.clientX), + y: Math.floor(event.clientY), + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx new file mode 100644 index 0000000..596dc96 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { ClickOutsideWrapper } from '@grafana/ui'; +import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider'; +import { PanelHeaderMenu } from './PanelHeaderMenu'; +import { DashboardModel, PanelModel } from '../../state'; + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + show: boolean; + onClose: () => void; +} + +export const PanelHeaderMenuWrapper: FC = ({ show, onClose, panel, dashboard }) => { + if (!show) { + return null; + } + + return ( + + + {({ items }) => { + return ; + }} + + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx new file mode 100644 index 0000000..16e9670 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { QueryResultMetaNotice } from '@grafana/data'; +import { Icon, Tooltip } from '@grafana/ui'; + +interface Props { + notice: QueryResultMetaNotice; + onClick: (e: React.SyntheticEvent, tab: string) => void; +} + +export const PanelHeaderNotice: FC = ({ notice, onClick }) => { + const iconName = + notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle'; + + return ( + + {notice.inspect ? ( +
    onClick(e, notice.inspect!)}> + +
    + ) : ( + + + + )} +
    + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx new file mode 100644 index 0000000..4e70fda --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx @@ -0,0 +1,39 @@ +import React, { FC, useCallback } from 'react'; +import { DataFrame, QueryResultMetaNotice } from '@grafana/data'; +import { PanelHeaderNotice } from './PanelHeaderNotice'; +import { locationService } from '@grafana/runtime'; + +interface Props { + panelId: number; + frames: DataFrame[]; +} + +export const PanelHeaderNotices: FC = ({ frames, panelId }) => { + const openInspect = useCallback( + (e: React.SyntheticEvent, tab: string) => { + e.stopPropagation(); + locationService.partial({ inspect: panelId, inspectTab: tab }); + }, + [panelId] + ); + + // dedupe on severity + const notices: Record = {}; + for (const frame of frames) { + if (!frame.meta || !frame.meta.notices) { + continue; + } + + for (const notice of frame.meta.notices) { + notices[notice.severity] = notice; + } + } + + return ( + <> + {Object.values(notices).map((notice) => ( + + ))} + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelPluginError.tsx b/public/app/features/dashboard/dashgrid/PanelPluginError.tsx new file mode 100644 index 0000000..fa2b65e --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelPluginError.tsx @@ -0,0 +1,85 @@ +// Libraries +import React, { PureComponent, ReactNode } from 'react'; + +// Types +import { AppNotificationSeverity } from 'app/types'; +import { Alert } from '@grafana/ui'; +import { PanelProps, PanelPlugin, PluginType, PanelPluginMeta } from '@grafana/data'; + +interface Props { + title: string; + text?: ReactNode; +} + +class PanelPluginError extends PureComponent { + constructor(props: Props) { + super(props); + } + + render() { + const style = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }; + + return ( +
    + +
    + ); + } +} + +export function getPanelPluginLoadError(meta: PanelPluginMeta, err: any): PanelPlugin { + const LoadError = class LoadError extends PureComponent { + render() { + const text = ( + <> + Check the server startup logs for more information.
    + If this plugin was loaded from Git, then make sure it was compiled. + + ); + return ; + } + }; + const plugin = new PanelPlugin(LoadError); + plugin.meta = meta; + plugin.loadError = true; + return plugin; +} + +export function getPanelPluginNotFound(id: string, silent?: boolean): PanelPlugin { + const NotFound = class NotFound extends PureComponent { + render() { + return ; + } + }; + + const plugin = new PanelPlugin(silent ? () => null : NotFound); + + plugin.meta = { + id: id, + name: id, + sort: 100, + type: PluginType.panel, + module: '', + baseUrl: '', + info: { + author: { + name: '', + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + }; + return plugin; +} diff --git a/public/app/features/dashboard/dashgrid/PanelResizer.tsx b/public/app/features/dashboard/dashgrid/PanelResizer.tsx new file mode 100644 index 0000000..3bd1913 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelResizer.tsx @@ -0,0 +1,77 @@ +import React, { PureComponent } from 'react'; +import { throttle } from 'lodash'; +import Draggable, { DraggableEventHandler } from 'react-draggable'; + +import { PanelModel } from '../state/PanelModel'; + +interface Props { + isEditing: boolean; + render: (styles: object) => JSX.Element; + panel: PanelModel; +} + +interface State { + editorHeight: number; +} + +export class PanelResizer extends PureComponent { + initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.3); + prevEditorHeight?: number; + throttledChangeHeight: (height: number) => void; + throttledResizeDone?: () => void; + noStyles: object = {}; + + constructor(props: Props) { + super(props); + + this.state = { + editorHeight: this.initialHeight, + }; + + this.throttledChangeHeight = throttle(this.changeHeight, 20, { trailing: true }); + } + + get largestHeight() { + return document.documentElement.scrollHeight * 0.9; + } + get smallestHeight() { + return 100; + } + + changeHeight = (height: number) => { + const sh = this.smallestHeight; + const lh = this.largestHeight; + height = height < sh ? sh : height; + height = height > lh ? lh : height; + + this.prevEditorHeight = this.state.editorHeight; + this.setState({ + editorHeight: height, + }); + }; + + onDrag: DraggableEventHandler = (evt, data) => { + const newHeight = this.state.editorHeight + data.y; + this.throttledChangeHeight(newHeight); + }; + + render() { + const { render, isEditing } = this.props; + const { editorHeight } = this.state; + + return ( + <> + {render(isEditing ? { height: editorHeight } : this.noStyles)} + {isEditing && ( +
    + +
    +
    +
    + +
    + )} + + ); + } +} diff --git a/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts b/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts new file mode 100644 index 0000000..12c8ad2 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts @@ -0,0 +1,171 @@ +import { + ByNamesMatcherMode, + DataFrame, + DynamicConfigValue, + FieldConfigSource, + FieldMatcherID, + FieldType, + getFieldDisplayName, + isSystemOverrideWithRef, + SystemConfigOverrideRule, +} from '@grafana/data'; +import { SeriesVisibilityChangeMode } from '@grafana/ui'; + +const displayOverrideRef = 'hideSeriesFrom'; +const isHideSeriesOverride = isSystemOverrideWithRef(displayOverrideRef); + +export function seriesVisibilityConfigFactory( + label: string, + mode: SeriesVisibilityChangeMode, + fieldConfig: FieldConfigSource, + data: DataFrame[] +) { + const { overrides } = fieldConfig; + + const displayName = label; + const currentIndex = overrides.findIndex(isHideSeriesOverride); + + if (currentIndex < 0) { + if (mode === SeriesVisibilityChangeMode.ToggleSelection) { + const override = createOverride([displayName]); + + return { + ...fieldConfig, + overrides: [override, ...fieldConfig.overrides], + }; + } + + const displayNames = getDisplayNames(data, displayName); + const override = createOverride(displayNames); + + return { + ...fieldConfig, + overrides: [override, ...fieldConfig.overrides], + }; + } + + const overridesCopy = Array.from(overrides); + const [current] = overridesCopy.splice(currentIndex, 1) as SystemConfigOverrideRule[]; + + if (mode === SeriesVisibilityChangeMode.ToggleSelection) { + const existing = getExistingDisplayNames(current); + + if (existing[0] === displayName && existing.length === 1) { + return { + ...fieldConfig, + overrides: overridesCopy, + }; + } + + const override = createOverride([displayName]); + + return { + ...fieldConfig, + overrides: [override, ...overridesCopy], + }; + } + + const override = createExtendedOverride(current, displayName); + + if (allFieldsAreExcluded(override, data)) { + return { + ...fieldConfig, + overrides: overridesCopy, + }; + } + + return { + ...fieldConfig, + overrides: [override, ...overridesCopy], + }; +} + +function createOverride( + names: string[], + mode = ByNamesMatcherMode.exclude, + property?: DynamicConfigValue +): SystemConfigOverrideRule { + property = property ?? { + id: 'custom.hideFrom', + value: { + viz: true, + legend: false, + tooltip: false, + }, + }; + + return { + __systemRef: displayOverrideRef, + matcher: { + id: FieldMatcherID.byNames, + options: { + mode: mode, + names: names, + prefix: mode === ByNamesMatcherMode.exclude ? 'All except:' : undefined, + readOnly: true, + }, + }, + properties: [ + { + ...property, + value: { + viz: true, + legend: false, + tooltip: false, + }, + }, + ], + }; +} + +const createExtendedOverride = ( + current: SystemConfigOverrideRule, + displayName: string, + mode = ByNamesMatcherMode.exclude +): SystemConfigOverrideRule => { + const property = current.properties.find((p) => p.id === 'custom.hideFrom'); + const existing = getExistingDisplayNames(current); + const index = existing.findIndex((name) => name === displayName); + + if (index < 0) { + existing.push(displayName); + } else { + existing.splice(index, 1); + } + + return createOverride(existing, mode, property); +}; + +const getExistingDisplayNames = (rule: SystemConfigOverrideRule): string[] => { + const names = rule.matcher.options?.names; + if (!Array.isArray(names)) { + return []; + } + return names; +}; + +const allFieldsAreExcluded = (override: SystemConfigOverrideRule, data: DataFrame[]): boolean => { + return getExistingDisplayNames(override).length === getDisplayNames(data).length; +}; + +const getDisplayNames = (data: DataFrame[], excludeName?: string): string[] => { + const unique = new Set(); + + for (const frame of data) { + for (const field of frame.fields) { + if (field.type !== FieldType.number) { + continue; + } + + const name = getFieldDisplayName(field, frame, data); + + if (name === excludeName) { + continue; + } + + unique.add(name); + } + } + + return Array.from(unique); +}; diff --git a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap new file mode 100644 index 0000000..2556cd8 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap @@ -0,0 +1,1131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +`; diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts new file mode 100644 index 0000000..1a39f74 --- /dev/null +++ b/public/app/features/dashboard/index.ts @@ -0,0 +1,8 @@ +// Services +import './services/DashboardLoaderSrv'; +import './services/DashboardSrv'; +// Components +import './components/DashExportModal'; +import './components/DashNav'; +import './components/VersionHistory'; +import './components/DashboardSettings'; diff --git a/public/app/features/dashboard/services/ChangeTracker.test.ts b/public/app/features/dashboard/services/ChangeTracker.test.ts new file mode 100644 index 0000000..a717518 --- /dev/null +++ b/public/app/features/dashboard/services/ChangeTracker.test.ts @@ -0,0 +1,162 @@ +import { ChangeTracker } from './ChangeTracker'; +import { DashboardModel } from '../state/DashboardModel'; +import { PanelModel } from '../state/PanelModel'; +import { setContextSrv } from '../../../core/services/context_srv'; + +function getDefaultDashboardModel(): DashboardModel { + return new DashboardModel({ + refresh: false, + panels: [ + { + id: 1, + type: 'graph', + gridPos: { x: 0, y: 0, w: 24, h: 6 }, + legend: { sortDesc: false }, + }, + { + id: 2, + type: 'row', + gridPos: { x: 0, y: 6, w: 24, h: 2 }, + collapsed: true, + panels: [ + { id: 3, type: 'graph', gridPos: { x: 0, y: 6, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 6, w: 12, h: 2 } }, + ], + }, + { id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } }, + ], + }); +} + +function getTestContext() { + const contextSrv: any = { isSignedIn: true, isEditor: true }; + setContextSrv(contextSrv); + const dash: any = getDefaultDashboardModel(); + const tracker = new ChangeTracker(); + const original: any = dash.getSaveModelClone(); + + return { dash, tracker, original, contextSrv }; +} + +describe('ChangeTracker', () => { + it('No changes should not have changes', () => { + const { tracker, original, dash } = getTestContext(); + expect(tracker.hasChanges(dash, original)).toBe(false); + }); + + it('Simple change should be registered', () => { + const { tracker, original, dash } = getTestContext(); + dash.title = 'google'; + expect(tracker.hasChanges(dash, original)).toBe(true); + }); + + it('Should ignore a lot of changes', () => { + const { tracker, original, dash } = getTestContext(); + dash.time = { from: '1h' }; + dash.refresh = true; + dash.schemaVersion = 10; + expect(tracker.hasChanges(dash, original)).toBe(false); + }); + + it('Should ignore .iteration changes', () => { + const { tracker, original, dash } = getTestContext(); + dash.iteration = new Date().getTime() + 1; + expect(tracker.hasChanges(dash, original)).toBe(false); + }); + + it('Should ignore row collapse change', () => { + const { tracker, original, dash } = getTestContext(); + dash.toggleRow(dash.panels[1]); + expect(tracker.hasChanges(dash, original)).toBe(false); + }); + + it('Should ignore panel legend changes', () => { + const { tracker, original, dash } = getTestContext(); + dash.panels[0].legend.sortDesc = true; + dash.panels[0].legend.sort = 'avg'; + expect(tracker.hasChanges(dash, original)).toBe(false); + }); + + it('Should ignore panel repeats', () => { + const { tracker, original, dash } = getTestContext(); + dash.panels.push(new PanelModel({ repeatPanelId: 10 })); + expect(tracker.hasChanges(dash, original)).toBe(false); + }); + + describe('ignoreChanges', () => { + describe('when called without original dashboard', () => { + it('then it should return true', () => { + const { tracker, dash } = getTestContext(); + expect(tracker.ignoreChanges(dash, null)).toBe(true); + }); + }); + + describe('when called without current dashboard', () => { + it('then it should return true', () => { + const { tracker, original } = getTestContext(); + expect(tracker.ignoreChanges((null as unknown) as DashboardModel, original)).toBe(true); + }); + }); + + describe('when called without meta in current dashboard', () => { + it('then it should return true', () => { + const { tracker, original, dash } = getTestContext(); + expect(tracker.ignoreChanges({ ...dash, meta: undefined }, original)).toBe(true); + }); + }); + + describe('when called for a viewer without save permissions', () => { + it('then it should return true', () => { + const { tracker, original, dash, contextSrv } = getTestContext(); + contextSrv.isEditor = false; + expect(tracker.ignoreChanges({ ...dash, meta: { canSave: false } }, original)).toBe(true); + }); + }); + + describe('when called for a viewer with save permissions', () => { + it('then it should return undefined', () => { + const { tracker, original, dash, contextSrv } = getTestContext(); + contextSrv.isEditor = false; + expect(tracker.ignoreChanges({ ...dash, meta: { canSave: true } }, original)).toBe(undefined); + }); + }); + + describe('when called for an user that is not signed in', () => { + it('then it should return true', () => { + const { tracker, original, dash, contextSrv } = getTestContext(); + contextSrv.isSignedIn = false; + expect(tracker.ignoreChanges({ ...dash, meta: { canSave: true } }, original)).toBe(true); + }); + }); + + describe('when called with fromScript', () => { + it('then it should return true', () => { + const { tracker, original, dash } = getTestContext(); + expect( + tracker.ignoreChanges({ ...dash, meta: { canSave: true, fromScript: true, fromFile: undefined } }, original) + ).toBe(true); + }); + }); + + describe('when called with fromFile', () => { + it('then it should return true', () => { + const { tracker, original, dash } = getTestContext(); + expect( + tracker.ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: true } }, original) + ).toBe(true); + }); + }); + + describe('when called with canSave but without fromScript and fromFile', () => { + it('then it should return false', () => { + const { tracker, original, dash } = getTestContext(); + expect( + tracker.ignoreChanges( + { ...dash, meta: { canSave: true, fromScript: undefined, fromFile: undefined } }, + original + ) + ).toBe(undefined); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/services/ChangeTracker.ts b/public/app/features/dashboard/services/ChangeTracker.ts new file mode 100644 index 0000000..f3e3fed --- /dev/null +++ b/public/app/features/dashboard/services/ChangeTracker.ts @@ -0,0 +1,161 @@ +import { each, filter, find } from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { contextSrv } from 'app/core/services/context_srv'; +import { appEvents } from 'app/core/app_events'; +import { UnsavedChangesModal } from '../components/SaveDashboard/UnsavedChangesModal'; +import { DashboardSavedEvent, ShowModalReactEvent } from '../../../types/events'; +import { locationService } from '@grafana/runtime'; +import angular from 'angular'; + +export class ChangeTracker { + init(dashboard: DashboardModel, originalCopyDelay: number) { + let original: object | null = null; + let originalPath = locationService.getLocation().pathname; + + // register events + const savedEventUnsub = appEvents.subscribe(DashboardSavedEvent, () => { + original = dashboard.getSaveModelClone(); + originalPath = locationService.getLocation().pathname; + }); + + if (originalCopyDelay && !dashboard.meta.fromExplore) { + setTimeout(() => { + // wait for different services to patch the dashboard (missing properties) + original = dashboard.getSaveModelClone(); + }, originalCopyDelay); + } else { + original = dashboard.getSaveModelClone(); + } + + const history = locationService.getHistory(); + + const blockUnsub = history.block((location) => { + if (originalPath === location.pathname) { + return; + } + + if (this.ignoreChanges(dashboard, original)) { + return; + } + + if (!this.hasChanges(dashboard, original!)) { + return; + } + + appEvents.publish( + new ShowModalReactEvent({ + component: UnsavedChangesModal, + props: { + dashboard: dashboard, + onSaveSuccess: () => { + original = dashboard.getSaveModelClone(); + history.push(location); + }, + onDiscard: () => { + original = dashboard.getSaveModelClone(); + history.push(location); + }, + }, + }) + ); + + return false; + }); + + const historyListenUnsub = history.listen((location) => { + if (originalPath !== location.pathname) { + blockUnsub(); + historyListenUnsub(); + savedEventUnsub.unsubscribe(); + } + }); + } + + // for some dashboards and users + // changes should be ignored + ignoreChanges(current: DashboardModel, original: object | null) { + if (!original) { + return true; + } + + // Ignore changes if the user has been signed out + if (!contextSrv.isSignedIn) { + return true; + } + + if (!current || !current.meta) { + return true; + } + + const { canSave, fromScript, fromFile } = current.meta; + if (!contextSrv.isEditor && !canSave) { + return true; + } + + return !canSave || fromScript || fromFile; + } + + // remove stuff that should not count in diff + cleanDashboardFromIgnoredChanges(dashData: any) { + // need to new up the domain model class to get access to expand / collapse row logic + const model = new DashboardModel(dashData); + + // Expand all rows before making comparison. This is required because row expand / collapse + // change order of panel array and panel positions. + model.expandRows(); + + const dash = model.getSaveModelClone(); + + // ignore time and refresh + dash.time = 0; + dash.refresh = 0; + dash.schemaVersion = 0; + dash.timezone = 0; + + // ignore iteration property + delete dash.iteration; + + dash.panels = filter(dash.panels, (panel) => { + if (panel.repeatPanelId) { + return false; + } + + // remove scopedVars + panel.scopedVars = undefined; + + // ignore panel legend sort + if (panel.legend) { + delete panel.legend.sort; + delete panel.legend.sortDesc; + } + + return true; + }); + + // ignore template variable values + each(dash.getVariables(), (variable: any) => { + variable.current = null; + variable.options = null; + variable.filters = null; + }); + + return dash; + } + + hasChanges(current: DashboardModel, original: any) { + const currentClean = this.cleanDashboardFromIgnoredChanges(current.getSaveModelClone()); + const originalClean = this.cleanDashboardFromIgnoredChanges(original); + + const currentTimepicker: any = find((currentClean as any).nav, { type: 'timepicker' }); + const originalTimepicker: any = find((originalClean as any).nav, { type: 'timepicker' }); + + if (currentTimepicker && originalTimepicker) { + currentTimepicker.now = originalTimepicker.now; + } + + const currentJson = angular.toJson(currentClean); + const originalJson = angular.toJson(originalClean); + + return currentJson !== originalJson; + } +} diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts new file mode 100644 index 0000000..4398d7a --- /dev/null +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -0,0 +1,150 @@ +import moment from 'moment'; // eslint-disable-line no-restricted-imports +// eslint-disable-next-line lodash/import-scope +import _, { isFunction } from 'lodash'; +import $ from 'jquery'; +import kbn from 'app/core/utils/kbn'; +import { AppEvents, dateMath, UrlQueryValue } from '@grafana/data'; +import impressionSrv from 'app/core/services/impression_srv'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { getDashboardSrv } from './DashboardSrv'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { getBackendSrv, locationService } from '@grafana/runtime'; +import { appEvents } from '../../../core/core'; + +export class DashboardLoaderSrv { + constructor() {} + _dashboardLoadFailed(title: string, snapshot?: boolean) { + snapshot = snapshot || false; + return { + meta: { + canStar: false, + isSnapshot: snapshot, + canDelete: false, + canSave: false, + canEdit: false, + dashboardNotFound: true, + }, + dashboard: { title }, + }; + } + + loadDashboard(type: UrlQueryValue, slug: any, uid: any) { + let promise; + + if (type === 'script') { + promise = this._loadScriptedDashboard(slug); + } else if (type === 'snapshot') { + promise = backendSrv.get('/api/snapshots/' + slug).catch(() => { + return this._dashboardLoadFailed('Snapshot not found', true); + }); + } else { + promise = backendSrv + .getDashboardByUid(uid) + .then((result: any) => { + if (result.meta.isFolder) { + appEvents.emit(AppEvents.alertError, ['Dashboard not found']); + throw new Error('Dashboard not found'); + } + return result; + }) + .catch(() => { + return this._dashboardLoadFailed('Not found', true); + }); + } + + promise.then((result: any) => { + if (result.meta.dashboardNotFound !== true) { + impressionSrv.addDashboardImpression(result.dashboard.id); + } + + return result; + }); + + return promise; + } + + _loadScriptedDashboard(file: string) { + const url = 'public/dashboards/' + file.replace(/\.(?!js)/, '/') + '?' + new Date().getTime(); + + return getBackendSrv() + .get(url) + .then(this._executeScript.bind(this)) + .then( + (result: any) => { + return { + meta: { + fromScript: true, + canDelete: false, + canSave: false, + canStar: false, + }, + dashboard: result.data, + }; + }, + (err: any) => { + console.error('Script dashboard error ' + err); + appEvents.emit(AppEvents.alertError, [ + 'Script Error', + 'Please make sure it exists and returns a valid dashboard', + ]); + return this._dashboardLoadFailed('Scripted dashboard'); + } + ); + } + + _executeScript(result: any) { + const services = { + dashboardSrv: getDashboardSrv(), + datasourceSrv: getDatasourceSrv(), + }; + const scriptFunc = new Function( + 'ARGS', + 'kbn', + 'dateMath', + '_', + 'moment', + 'window', + 'document', + '$', + 'jQuery', + 'services', + result + ); + const scriptResult = scriptFunc( + locationService.getSearchObject(), + kbn, + dateMath, + _, + moment, + window, + document, + $, + $, + services + ); + + // Handle async dashboard scripts + if (isFunction(scriptResult)) { + return new Promise((resolve) => { + scriptResult((dashboard: any) => { + resolve({ data: dashboard }); + }); + }); + } + + return { data: scriptResult }; + } +} + +let dashboardLoaderSrv = new DashboardLoaderSrv(); +export { dashboardLoaderSrv }; + +/** @internal + * Used for tests only + */ +export const setDashboardLoaderSrv = (srv: DashboardLoaderSrv) => { + if (process.env.NODE_ENV !== 'test') { + throw new Error('dashboardLoaderSrv can be only overriden in test environment'); + } + dashboardLoaderSrv = srv; +}; diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts new file mode 100644 index 0000000..4f9c16b --- /dev/null +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -0,0 +1,90 @@ +import coreModule from 'app/core/core_module'; +import { appEvents } from 'app/core/app_events'; +import { DashboardModel } from '../state/DashboardModel'; +import { removePanel } from '../utils/panel'; +import { DashboardMeta } from 'app/types'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { promiseToDigest } from '../../../core/utils/promiseToDigest'; +import { saveDashboard } from 'app/features/manage-dashboards/state/actions'; +import { RemovePanelEvent } from '../../../types/events'; + +export class DashboardSrv { + dashboard?: DashboardModel; + + /** @ngInject */ + constructor(private $rootScope: GrafanaRootScope) { + appEvents.subscribe(RemovePanelEvent, (e) => this.onRemovePanel(e.payload)); + } + + create(dashboard: any, meta: DashboardMeta) { + return new DashboardModel(dashboard, meta); + } + + setCurrent(dashboard: DashboardModel) { + this.dashboard = dashboard; + } + + getCurrent(): DashboardModel | undefined { + if (!this.dashboard) { + console.warn('Calling getDashboardSrv().getCurrent() without calling getDashboardSrv().setCurrent() first.'); + } + return this.dashboard; + } + + onRemovePanel = (panelId: number) => { + const dashboard = this.getCurrent(); + if (dashboard) { + removePanel(dashboard, dashboard.getPanelById(panelId)!, true); + } + }; + + saveJSONDashboard(json: string) { + const parsedJson = JSON.parse(json); + return saveDashboard({ + dashboard: parsedJson, + folderId: this.dashboard?.meta.folderId || parsedJson.folderId, + }); + } + + starDashboard(dashboardId: string, isStarred: any) { + let promise; + + if (isStarred) { + promise = promiseToDigest(this.$rootScope)( + backendSrv.delete('/api/user/stars/dashboard/' + dashboardId).then(() => { + return false; + }) + ); + } else { + promise = promiseToDigest(this.$rootScope)( + backendSrv.post('/api/user/stars/dashboard/' + dashboardId).then(() => { + return true; + }) + ); + } + + return promise.then((res: boolean) => { + if (this.dashboard && this.dashboard.id === dashboardId) { + this.dashboard.meta.isStarred = res; + } + return res; + }); + } +} + +coreModule.service('dashboardSrv', DashboardSrv); + +// +// Code below is to export the service to React components +// + +let singletonInstance: DashboardSrv; + +export function setDashboardSrv(instance: DashboardSrv) { + singletonInstance = instance; +} + +export function getDashboardSrv(): DashboardSrv { + return singletonInstance; +} diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts new file mode 100644 index 0000000..163a7c5 --- /dev/null +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -0,0 +1,190 @@ +import { TimeSrv } from './TimeSrv'; +import { ContextSrvStub } from 'test/specs/helpers'; +import { isDateTime, dateTime } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +jest.mock('app/core/core', () => ({ + appEvents: { + subscribe: () => {}, + }, +})); + +describe('timeSrv', () => { + let timeSrv: TimeSrv; + + const _dashboard: any = { + time: { from: 'now-6h', to: 'now' }, + getTimezone: jest.fn(() => 'browser'), + timeRangeUpdated: jest.fn(() => {}), + }; + + beforeEach(() => { + timeSrv = new TimeSrv(new ContextSrvStub() as any); + timeSrv.init(_dashboard); + _dashboard.refresh = false; + }); + + describe('timeRange', () => { + it('should return unparsed when parse is false', () => { + timeSrv.setTime({ from: 'now', to: 'now-1h' }); + const time = timeSrv.timeRange(); + expect(time.raw.from).toBe('now'); + expect(time.raw.to).toBe('now-1h'); + }); + + it('should return parsed when parse is true', () => { + timeSrv.setTime({ from: 'now', to: 'now-1h' }); + const time = timeSrv.timeRange(); + expect(isDateTime(time.from)).toBe(true); + expect(isDateTime(time.to)).toBe(true); + }); + }); + + describe('init time from url', () => { + it('should handle relative times', () => { + locationService.push('/d/id?from=now-2d&to=now'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.raw.from).toBe('now-2d'); + expect(time.raw.to).toBe('now'); + }); + + it('should handle formatted dates', () => { + locationService.push('/d/id?from=20140410T052010&to=20140520T031022'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(new Date('2014-04-10T05:20:10Z').getTime()); + expect(time.to.valueOf()).toEqual(new Date('2014-05-20T03:10:22Z').getTime()); + }); + + it('should ignore refresh if time absolute', () => { + locationService.push('/d/id?from=20140410T052010&to=20140520T031022'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + // dashboard saved with refresh on + _dashboard.refresh = true; + timeSrv.init(_dashboard); + + expect(timeSrv.refresh).toBe(false); + }); + + it('should handle formatted dates without time', () => { + locationService.push('/d/id?from=20140410&to=20140520'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(new Date('2014-04-10T00:00:00Z').getTime()); + expect(time.to.valueOf()).toEqual(new Date('2014-05-20T00:00:00Z').getTime()); + }); + + it('should handle epochs', () => { + locationService.push('/d/id?from=1410337646373&to=1410337665699'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(1410337646373); + expect(time.to.valueOf()).toEqual(1410337665699); + }); + + it('should handle epochs that look like formatted date without time', () => { + locationService.push('/d/id?from=20149999&to=20159999'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(20149999); + expect(time.to.valueOf()).toEqual(20159999); + }); + + it('should handle epochs that look like formatted date', () => { + locationService.push('/d/id?from=201499991234567&to=201599991234567'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(201499991234567); + expect(time.to.valueOf()).toEqual(201599991234567); + }); + + it('should handle bad dates', () => { + locationService.push('/d/id?from=20151126T00010%3C%2Fp%3E%3Cspan%20class&to=now'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + _dashboard.time.from = 'now-6h'; + timeSrv.init(_dashboard); + expect(timeSrv.time.from).toEqual('now-6h'); + expect(timeSrv.time.to).toEqual('now'); + }); + + describe('data point windowing', () => { + it('handles time window specfied as interval string', () => { + locationService.push('/d/id?time=1410337645000&time.window=10s'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(1410337640000); + expect(time.to.valueOf()).toEqual(1410337650000); + }); + + it('handles time window specified in ms', () => { + locationService.push('/d/id?time=1410337645000&time.window=10000'); + + timeSrv = new TimeSrv(new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(1410337640000); + expect(time.to.valueOf()).toEqual(1410337650000); + }); + }); + }); + + describe('setTime', () => { + it('should return disable refresh if refresh is disabled for any range', () => { + _dashboard.refresh = false; + + timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' }); + expect(_dashboard.refresh).toBe(false); + }); + + it('should restore refresh for absolute time range', () => { + _dashboard.refresh = '30s'; + + timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' }); + expect(_dashboard.refresh).toBe('30s'); + }); + + it('should restore refresh after relative time range is set', () => { + _dashboard.refresh = '10s'; + timeSrv.setTime({ + from: dateTime([2011, 1, 1]), + to: dateTime([2015, 1, 1]), + }); + expect(_dashboard.refresh).toBe(false); + timeSrv.setTime({ from: '2011-01-01', to: 'now' }); + expect(_dashboard.refresh).toBe('10s'); + }); + + it('should keep refresh after relative time range is changed and now delay exists', () => { + _dashboard.refresh = '10s'; + timeSrv.setTime({ from: 'now-1h', to: 'now-10s' }); + expect(_dashboard.refresh).toBe('10s'); + }); + }); +}); diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts new file mode 100644 index 0000000..57458cb --- /dev/null +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -0,0 +1,324 @@ +import { cloneDeep, extend, isString } from 'lodash'; +import { + dateMath, + dateTime, + getDefaultTimeRange, + isDateTime, + rangeUtil, + RawTimeRange, + TimeRange, + toUtc, +} from '@grafana/data'; +import { DashboardModel } from '../state/DashboardModel'; +import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { config } from 'app/core/config'; +import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; +import { locationService } from '@grafana/runtime'; +import { ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../types/events'; +import { contextSrv, ContextSrv } from 'app/core/services/context_srv'; +import appEvents from 'app/core/app_events'; + +export class TimeSrv { + time: any; + refreshTimer: any; + refresh: any; + oldRefresh: string | null | undefined; + dashboard?: DashboardModel; + timeAtLoad: any; + private autoRefreshBlocked?: boolean; + + constructor(private contextSrv: ContextSrv) { + // default time + this.time = getDefaultTimeRange().raw; + this.refreshDashboard = this.refreshDashboard.bind(this); + + appEvents.subscribe(ZoomOutEvent, (e) => { + this.zoomOut(e.payload); + }); + + appEvents.subscribe(ShiftTimeEvent, (e) => { + this.shiftTime(e.payload); + }); + + document.addEventListener('visibilitychange', () => { + if (this.autoRefreshBlocked && document.visibilityState === 'visible') { + this.autoRefreshBlocked = false; + this.refreshDashboard(); + } + }); + } + + init(dashboard: DashboardModel) { + this.dashboard = dashboard; + this.time = dashboard.time; + this.refresh = dashboard.refresh; + + this.initTimeFromUrl(); + this.parseTime(); + + // remember time at load so we can go back to it + this.timeAtLoad = cloneDeep(this.time); + + if (this.refresh) { + this.setAutoRefresh(this.refresh); + } + } + + getValidIntervals(intervals: string[]): string[] { + if (!this.contextSrv.minRefreshInterval) { + return intervals; + } + + return intervals.filter((str) => str !== '').filter(this.contextSrv.isAllowedInterval); + } + + private parseTime() { + // when absolute time is saved in json it is turned to a string + if (isString(this.time.from) && this.time.from.indexOf('Z') >= 0) { + this.time.from = dateTime(this.time.from).utc(); + } + if (isString(this.time.to) && this.time.to.indexOf('Z') >= 0) { + this.time.to = dateTime(this.time.to).utc(); + } + } + + private parseUrlParam(value: any) { + if (value.indexOf('now') !== -1) { + return value; + } + if (value.length === 8) { + const utcValue = toUtc(value, 'YYYYMMDD'); + if (utcValue.isValid()) { + return utcValue; + } + } else if (value.length === 15) { + const utcValue = toUtc(value, 'YYYYMMDDTHHmmss'); + if (utcValue.isValid()) { + return utcValue; + } + } + + if (!isNaN(value)) { + const epoch = parseInt(value, 10); + return toUtc(epoch); + } + + return null; + } + + private getTimeWindow(time: string, timeWindow: string) { + const valueTime = parseInt(time, 10); + let timeWindowMs; + + if (timeWindow.match(/^\d+$/) && parseInt(timeWindow, 10)) { + // when time window specified in ms + timeWindowMs = parseInt(timeWindow, 10); + } else { + timeWindowMs = rangeUtil.intervalToMs(timeWindow); + } + + return { + from: toUtc(valueTime - timeWindowMs / 2), + to: toUtc(valueTime + timeWindowMs / 2), + }; + } + + private initTimeFromUrl() { + const params = locationService.getSearch(); + + if (params.get('time') && params.get('time.window')) { + this.time = this.getTimeWindow(params.get('time')!, params.get('time.window')!); + } + + if (params.get('from')) { + this.time.from = this.parseUrlParam(params.get('from')!) || this.time.from; + } + + if (params.get('to')) { + this.time.to = this.parseUrlParam(params.get('to')!) || this.time.to; + } + + // if absolute ignore refresh option saved to dashboard + if (params.get('to') && params.get('to')!.indexOf('now') === -1) { + this.refresh = false; + if (this.dashboard) { + this.dashboard.refresh = false; + } + } + + let paramsJSON: Record = {}; + params.forEach(function (value, key) { + paramsJSON[key] = value; + }); + + // but if refresh explicitly set then use that + this.refresh = getRefreshFromUrl({ + params: paramsJSON, + currentRefresh: this.refresh, + refreshIntervals: this.dashboard?.timepicker?.refresh_intervals, + isAllowedIntervalFn: this.contextSrv.isAllowedInterval, + minRefreshInterval: config.minRefreshInterval, + }); + } + + updateTimeRangeFromUrl() { + const params = locationService.getSearch(); + + if (params.get('left')) { + return; // explore handles this; + } + + const urlRange = this.timeRangeForUrl(); + const from = params.get('from'); + const to = params.get('to'); + + // check if url has time range + if (from && to) { + // is it different from what our current time range? + if (from !== urlRange.from || to !== urlRange.to) { + // issue update + this.initTimeFromUrl(); + this.setTime(this.time, true); + } + } else if (this.timeHasChangedSinceLoad()) { + this.setTime(this.timeAtLoad, true); + } + } + + private timeHasChangedSinceLoad() { + return this.timeAtLoad && (this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to); + } + + setAutoRefresh(interval: any) { + if (this.dashboard) { + this.dashboard.refresh = interval; + } + + this.stopAutoRefresh(); + + if (interval) { + const validInterval = this.contextSrv.getValidInterval(interval); + const intervalMs = rangeUtil.intervalToMs(validInterval); + + this.refreshTimer = setTimeout(() => { + this.startNextRefreshTimer(intervalMs); + this.refreshDashboard(); + }, intervalMs); + } + + if (interval) { + const refresh = this.contextSrv.getValidInterval(interval); + locationService.partial({ refresh }, true); + } else { + locationService.partial({ refresh: null }, true); + } + } + + refreshDashboard() { + this.dashboard?.timeRangeUpdated(this.timeRange()); + } + + private startNextRefreshTimer(afterMs: number) { + this.refreshTimer = setTimeout(() => { + this.startNextRefreshTimer(afterMs); + if (this.contextSrv.isGrafanaVisible()) { + this.refreshDashboard(); + } else { + this.autoRefreshBlocked = true; + } + }, afterMs); + } + + stopAutoRefresh() { + clearTimeout(this.refreshTimer); + } + + setTime(time: RawTimeRange, fromRouteUpdate?: boolean) { + extend(this.time, time); + + // disable refresh if zoom in or zoom out + if (isDateTime(time.to)) { + this.oldRefresh = this.dashboard?.refresh || this.oldRefresh; + this.setAutoRefresh(false); + } else if (this.oldRefresh && this.oldRefresh !== this.dashboard?.refresh) { + this.setAutoRefresh(this.oldRefresh); + this.oldRefresh = null; + } + + // update url + if (fromRouteUpdate !== true) { + const urlRange = this.timeRangeForUrl(); + const urlParams = locationService.getSearch(); + + urlParams.set('from', urlRange.from.toString()); + urlParams.set('to', urlRange.to.toString()); + + locationService.push({ + ...locationService.getLocation(), + search: urlParams.toString(), + }); + } + + this.refreshDashboard(); + } + + timeRangeForUrl = () => { + const range = this.timeRange().raw; + + if (isDateTime(range.from)) { + range.from = range.from.valueOf().toString(); + } + if (isDateTime(range.to)) { + range.to = range.to.valueOf().toString(); + } + + return range; + }; + + timeRange(): TimeRange { + // make copies if they are moment (do not want to return out internal moment, because they are mutable!) + const raw = { + from: isDateTime(this.time.from) ? dateTime(this.time.from) : this.time.from, + to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to, + }; + + const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined; + + return { + from: dateMath.parse(raw.from, false, timezone)!, + to: dateMath.parse(raw.to, true, timezone)!, + raw: raw, + }; + } + + zoomOut(factor: number) { + const range = this.timeRange(); + const { from, to } = getZoomedTimeRange(range, factor); + + this.setTime({ from: toUtc(from), to: toUtc(to) }); + } + + shiftTime(direction: ShiftTimeEventPayload) { + const range = this.timeRange(); + const { from, to } = getShiftedTimeRange(direction, range); + + this.setTime({ + from: toUtc(from), + to: toUtc(to), + }); + } +} + +let singleton: TimeSrv | undefined; + +export function setTimeSrv(srv: TimeSrv) { + singleton = srv; +} + +export function getTimeSrv(): TimeSrv { + if (!singleton) { + singleton = new TimeSrv(contextSrv); + } + + return singleton; +} diff --git a/public/app/features/dashboard/services/__mocks__/ChangeTracker.ts b/public/app/features/dashboard/services/__mocks__/ChangeTracker.ts new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/public/app/features/dashboard/services/__mocks__/ChangeTracker.ts @@ -0,0 +1,9 @@ +import { DashboardModel } from '../../state/DashboardModel'; + +export class ChangeTracker { + initCalled = false; + + init(dashboard: DashboardModel, originalCopyDelay: number) { + this.initCalled = true; + } +} diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts new file mode 100644 index 0000000..32a17bc --- /dev/null +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -0,0 +1,1277 @@ +import { each, map } from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { PanelModel } from '../state/PanelModel'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; +import { expect } from 'test/lib/common'; +import { DataLinkBuiltInVars, MappingType } from '@grafana/data'; +import { VariableHide } from '../../variables/types'; +import { config } from 'app/core/config'; +import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; + +jest.mock('app/core/services/context_srv', () => ({})); + +describe('DashboardModel', () => { + describe('when creating dashboard with old schema', () => { + let model: any; + let graph: any; + let singlestat: any; + let table: any; + let singlestatGauge: any; + + config.panels = { + stat: getPanelPlugin({ id: 'stat' }).meta, + gauge: getPanelPlugin({ id: 'gauge' }).meta, + }; + + beforeEach(() => { + model = new DashboardModel({ + services: { + filter: { time: { from: 'now-1d', to: 'now' }, list: [{}] }, + }, + pulldowns: [ + { type: 'filtering', enable: true }, + { type: 'annotations', enable: true, annotations: [{ name: 'old' }] }, + ], + panels: [ + { + type: 'graph', + legend: true, + aliasYAxis: { test: 2 }, + y_formats: ['kbyte', 'ms'], + grid: { + min: 1, + max: 10, + rightMin: 5, + rightMax: 15, + leftLogBase: 1, + rightLogBase: 2, + threshold1: 200, + threshold2: 400, + threshold1Color: 'yellow', + threshold2Color: 'red', + }, + leftYAxisLabel: 'left label', + targets: [{ refId: 'A' }, {}], + }, + { + type: 'singlestat', + legend: true, + thresholds: '10,20,30', + colors: ['#FF0000', 'green', 'orange'], + aliasYAxis: { test: 2 }, + grid: { min: 1, max: 10 }, + targets: [{ refId: 'A' }, {}], + }, + { + type: 'singlestat', + thresholds: '10,20,30', + colors: ['#FF0000', 'green', 'orange'], + gauge: { + show: true, + thresholdMarkers: true, + thresholdLabels: false, + }, + grid: { min: 1, max: 10 }, + }, + { + type: 'table', + legend: true, + styles: [{ thresholds: ['10', '20', '30'] }, { thresholds: ['100', '200', '300'] }], + targets: [{ refId: 'A' }, {}], + }, + ], + }); + + graph = model.panels[0]; + singlestat = model.panels[1]; + singlestatGauge = model.panels[2]; + table = model.panels[3]; + }); + + it('should have title', () => { + expect(model.title).toBe('No Title'); + }); + + it('should have panel id', () => { + expect(graph.id).toBe(1); + }); + + it('should move time and filtering list', () => { + expect(model.time.from).toBe('now-1d'); + expect(model.templating.list[0].allFormat).toBe('glob'); + }); + + it('graphite panel should change name too graph', () => { + expect(graph.type).toBe('graph'); + }); + + it('singlestat panel should be mapped to stat panel', () => { + expect(singlestat.type).toBe('stat'); + expect(singlestat.fieldConfig.defaults.thresholds.steps[2].value).toBe(30); + expect(singlestat.fieldConfig.defaults.thresholds.steps[0].color).toBe('#FF0000'); + }); + + it('singlestat panel should be mapped to gauge panel', () => { + expect(singlestatGauge.type).toBe('gauge'); + expect(singlestatGauge.options.showThresholdMarkers).toBe(true); + expect(singlestatGauge.options.showThresholdLabels).toBe(false); + }); + + it('queries without refId should get it', () => { + expect(graph.targets[1].refId).toBe('B'); + }); + + it('update legend setting', () => { + expect(graph.legend.show).toBe(true); + }); + + it('move aliasYAxis to series override', () => { + expect(graph.seriesOverrides[0].alias).toBe('test'); + expect(graph.seriesOverrides[0].yaxis).toBe(2); + }); + + it('should move pulldowns to new schema', () => { + expect(model.annotations.list[1].name).toBe('old'); + }); + + it('table panel should only have two thresholds values', () => { + expect(table.styles[0].thresholds[0]).toBe('20'); + expect(table.styles[0].thresholds[1]).toBe('30'); + expect(table.styles[1].thresholds[0]).toBe('200'); + expect(table.styles[1].thresholds[1]).toBe('300'); + }); + + it('table type should be deprecated', () => { + expect(table.type).toBe('table-old'); + }); + + it('graph grid to yaxes options', () => { + expect(graph.yaxes[0].min).toBe(1); + expect(graph.yaxes[0].max).toBe(10); + expect(graph.yaxes[0].format).toBe('kbyte'); + expect(graph.yaxes[0].label).toBe('left label'); + expect(graph.yaxes[0].logBase).toBe(1); + expect(graph.yaxes[1].min).toBe(5); + expect(graph.yaxes[1].max).toBe(15); + expect(graph.yaxes[1].format).toBe('ms'); + expect(graph.yaxes[1].logBase).toBe(2); + + expect(graph.grid.rightMax).toBe(undefined); + expect(graph.grid.rightLogBase).toBe(undefined); + expect(graph.y_formats).toBe(undefined); + }); + + it('dashboard schema version should be set to latest', () => { + expect(model.schemaVersion).toBe(30); + }); + + it('graph thresholds should be migrated', () => { + expect(graph.thresholds.length).toBe(2); + expect(graph.thresholds[0].op).toBe('gt'); + expect(graph.thresholds[0].value).toBe(200); + expect(graph.thresholds[0].fillColor).toBe('yellow'); + expect(graph.thresholds[1].value).toBe(400); + expect(graph.thresholds[1].fillColor).toBe('red'); + }); + + it('graph thresholds should be migrated onto specified thresholds', () => { + model = new DashboardModel({ + panels: [ + { + type: 'graph', + y_formats: ['kbyte', 'ms'], + grid: { + threshold1: 200, + threshold2: 400, + }, + thresholds: [{ value: 100 }], + }, + ], + }); + graph = model.panels[0]; + expect(graph.thresholds.length).toBe(3); + expect(graph.thresholds[0].value).toBe(100); + expect(graph.thresholds[1].value).toBe(200); + expect(graph.thresholds[2].value).toBe(400); + }); + }); + + describe('when migrating to the grid layout', () => { + let model: any; + + beforeEach(() => { + model = { + rows: [], + }; + }); + + it('should create proper grid', () => { + model.rows = [createRow({ collapse: false, height: 8 }, [[6], [6]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 12, h: 8 }, + { x: 12, y: 0, w: 12, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add special "row" panel if row is collapsed', () => { + model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]]), createRow({ height: 8 }, [[12]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 24, h: 8 }, // row + { x: 0, y: 2, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add special "row" panel if row has visible title', () => { + model.rows = [ + createRow({ showTitle: true, title: 'Row', height: 8 }, [[6], [6]]), + createRow({ height: 8 }, [[12]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 12, h: 8 }, + { x: 12, y: 1, w: 12, h: 8 }, + { x: 0, y: 9, w: 24, h: 8 }, // row + { x: 0, y: 10, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should not add "row" panel if row has not visible title or not collapsed', () => { + model.rows = [ + createRow({ collapse: true, height: 8 }, [[12]]), + createRow({ height: 8 }, [[12]]), + createRow({ height: 8 }, [[12], [6], [6]]), + createRow({ collapse: true, height: 8 }, [[12]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 24, h: 8 }, // row + { x: 0, y: 2, w: 24, h: 8 }, + { x: 0, y: 10, w: 24, h: 8 }, // row + { x: 0, y: 11, w: 24, h: 8 }, + { x: 0, y: 19, w: 12, h: 8 }, + { x: 12, y: 19, w: 12, h: 8 }, + { x: 0, y: 27, w: 24, h: 8 }, // row + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add all rows if even one collapsed or titled row is present', () => { + model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]]), createRow({ height: 8 }, [[12]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 24, h: 8 }, // row + { x: 0, y: 2, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should properly place panels with fixed height', () => { + model.rows = [ + createRow({ height: 6 }, [[6], [6, 3], [6, 3]]), + createRow({ height: 6 }, [[4], [4], [4, 3], [4, 3]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 12, h: 6 }, + { x: 12, y: 0, w: 12, h: 3 }, + { x: 12, y: 3, w: 12, h: 3 }, + { x: 0, y: 6, w: 8, h: 6 }, + { x: 8, y: 6, w: 8, h: 6 }, + { x: 16, y: 6, w: 8, h: 3 }, + { x: 16, y: 9, w: 8, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should place panel to the right side of panel having bigger height', () => { + model.rows = [createRow({ height: 6 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 6 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 6 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should fill current row if it possible', () => { + model.rows = [createRow({ height: 9 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 9 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 6 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + { x: 8, y: 6, w: 16, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should fill current row if it possible (2)', () => { + model.rows = [createRow({ height: 8 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 8 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 6 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + { x: 8, y: 6, w: 16, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should fill current row if panel height more than row height', () => { + model.rows = [createRow({ height: 6 }, [[4], [2, 3], [4, 8], [2, 3], [2, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 6 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 8 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should wrap panels to multiple rows', () => { + model.rows = [createRow({ height: 6 }, [[6], [6], [12], [6], [3], [3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 12, h: 6 }, + { x: 12, y: 0, w: 12, h: 6 }, + { x: 0, y: 6, w: 24, h: 6 }, + { x: 0, y: 12, w: 12, h: 6 }, + { x: 12, y: 12, w: 6, h: 6 }, + { x: 18, y: 12, w: 6, h: 6 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add repeated row if repeat set', () => { + model.rows = [ + createRow({ showTitle: true, title: 'Row', height: 8, repeat: 'server' }, [[6]]), + createRow({ height: 8 }, [[12]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, + { x: 0, y: 1, w: 12, h: 8 }, + { x: 0, y: 9, w: 24, h: 8 }, + { x: 0, y: 10, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + expect(dashboard.panels[0].repeat).toBe('server'); + expect(dashboard.panels[1].repeat).toBeUndefined(); + expect(dashboard.panels[2].repeat).toBeUndefined(); + expect(dashboard.panels[3].repeat).toBeUndefined(); + }); + + it('should ignore repeated row', () => { + model.rows = [ + createRow({ showTitle: true, title: 'Row1', height: 8, repeat: 'server' }, [[6]]), + createRow( + { + showTitle: true, + title: 'Row2', + height: 8, + repeatIteration: 12313, + repeatRowId: 1, + }, + [[6]] + ), + ]; + + const dashboard = new DashboardModel(model); + expect(dashboard.panels[0].repeat).toBe('server'); + expect(dashboard.panels.length).toBe(2); + }); + + it('should assign id', () => { + model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])]; + model.rows[0].panels[0] = {}; + + const dashboard = new DashboardModel(model); + expect(dashboard.panels[0].id).toBe(1); + }); + }); + + describe('when migrating from minSpan to maxPerRow', () => { + it('maxPerRow should be correct', () => { + const model = { + panels: [{ minSpan: 8 }], + }; + const dashboard = new DashboardModel(model); + expect(dashboard.panels[0].maxPerRow).toBe(3); + }); + }); + + describe('when migrating panel links', () => { + let model: any; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + links: [ + { + url: 'http://mylink.com', + keepTime: true, + title: 'test', + }, + { + url: 'http://mylink.com?existingParam', + params: 'customParam', + title: 'test', + }, + { + url: 'http://mylink.com?existingParam', + includeVars: true, + title: 'test', + }, + { + dashboard: 'my other dashboard', + title: 'test', + }, + { + dashUri: '', + title: 'test', + }, + { + type: 'dashboard', + keepTime: true, + }, + ], + }, + ], + }); + }); + + it('should add keepTime as variable', () => { + expect(model.panels[0].links[0].url).toBe(`http://mylink.com?$${DataLinkBuiltInVars.keepTime}`); + }); + + it('should add params to url', () => { + expect(model.panels[0].links[1].url).toBe('http://mylink.com?existingParam&customParam'); + }); + + it('should add includeVars to url', () => { + expect(model.panels[0].links[2].url).toBe(`http://mylink.com?existingParam&$${DataLinkBuiltInVars.includeVars}`); + }); + + it('should slugify dashboard name', () => { + expect(model.panels[0].links[3].url).toBe(`dashboard/db/my-other-dashboard`); + }); + }); + + describe('when migrating variables', () => { + let model: any; + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + //graph panel + options: { + dataLinks: [ + { + url: 'http://mylink.com?series=${__series_name}', + }, + { + url: 'http://mylink.com?series=${__value_time}', + }, + ], + }, + }, + { + // panel with field options + options: { + fieldOptions: { + defaults: { + links: [ + { + url: 'http://mylink.com?series=${__series_name}', + }, + { + url: 'http://mylink.com?series=${__value_time}', + }, + ], + title: '$__cell_0 * $__field_name * $__series_name', + }, + }, + }, + }, + ], + }); + }); + + describe('data links', () => { + it('should replace __series_name variable with __series.name', () => { + expect(model.panels[0].options.dataLinks[0].url).toBe('http://mylink.com?series=${__series.name}'); + expect(model.panels[1].options.fieldOptions.defaults.links[0].url).toBe( + 'http://mylink.com?series=${__series.name}' + ); + }); + + it('should replace __value_time variable with __value.time', () => { + expect(model.panels[0].options.dataLinks[1].url).toBe('http://mylink.com?series=${__value.time}'); + expect(model.panels[1].options.fieldOptions.defaults.links[1].url).toBe( + 'http://mylink.com?series=${__value.time}' + ); + }); + }); + + describe('field display', () => { + it('should replace __series_name and __field_name variables with new syntax', () => { + expect(model.panels[1].options.fieldOptions.defaults.title).toBe( + '$__cell_0 * ${__field.name} * ${__series.name}' + ); + }); + }); + }); + + describe('when migrating labels from DataFrame to Field', () => { + let model: any; + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + //graph panel + options: { + dataLinks: [ + { + url: 'http://mylink.com?series=${__series.labels}&${__series.labels.a}', + }, + ], + }, + }, + { + // panel with field options + options: { + fieldOptions: { + defaults: { + links: [ + { + url: 'http://mylink.com?series=${__series.labels}&${__series.labels.x}', + }, + ], + }, + }, + }, + }, + ], + }); + }); + + describe('data links', () => { + it('should replace __series.label variable with __field.label', () => { + expect(model.panels[0].options.dataLinks[0].url).toBe( + 'http://mylink.com?series=${__field.labels}&${__field.labels.a}' + ); + expect(model.panels[1].options.fieldOptions.defaults.links[0].url).toBe( + 'http://mylink.com?series=${__field.labels}&${__field.labels.x}' + ); + }); + }); + }); + + describe('when migrating variables with multi support', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + multi: false, + current: { + value: ['value'], + text: ['text'], + }, + }, + { + multi: true, + current: { + value: ['value'], + text: ['text'], + }, + }, + ], + }, + }); + }); + + it('should have two variables after migration', () => { + expect(model.templating.list.length).toBe(2); + }); + + it('should be migrated if being out of sync', () => { + expect(model.templating.list[0].multi).toBe(false); + expect(model.templating.list[0].current).toEqual({ + text: 'text', + value: 'value', + }); + }); + + it('should not be migrated if being in sync', () => { + expect(model.templating.list[1].multi).toBe(true); + expect(model.templating.list[1].current).toEqual({ + text: ['text'], + value: ['value'], + }); + }); + }); + + describe('when migrating variables with tags', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + type: 'query', + tags: ['Africa', 'America', 'Asia', 'Europe'], + tagsQuery: 'select datacenter from x', + tagValuesQuery: 'select value from x where datacenter = xyz', + useTags: true, + }, + { + type: 'query', + current: { + tags: [ + { + selected: true, + text: 'America', + values: ['server-us-east', 'server-us-central', 'server-us-west'], + valuesText: 'server-us-east + server-us-central + server-us-west', + }, + { + selected: true, + text: 'Europe', + values: ['server-eu-east', 'server-eu-west'], + valuesText: 'server-eu-east + server-eu-west', + }, + ], + text: 'server-us-east + server-us-central + server-us-west + server-eu-east + server-eu-west', + value: ['server-us-east', 'server-us-central', 'server-us-west', 'server-eu-east', 'server-eu-west'], + }, + tags: ['Africa', 'America', 'Asia', 'Europe'], + tagsQuery: 'select datacenter from x', + tagValuesQuery: 'select value from x where datacenter = xyz', + useTags: true, + }, + { + type: 'query', + tags: [ + { text: 'Africa', selected: false }, + { text: 'America', selected: true }, + { text: 'Asia', selected: false }, + { text: 'Europe', selected: false }, + ], + tagsQuery: 'select datacenter from x', + tagValuesQuery: 'select value from x where datacenter = xyz', + useTags: true, + }, + ], + }, + }); + }); + + it('should have three variables after migration', () => { + expect(model.templating.list.length).toBe(3); + }); + + it('should have no tags', () => { + expect(model.templating.list[0].tags).toBeUndefined(); + expect(model.templating.list[1].tags).toBeUndefined(); + expect(model.templating.list[2].tags).toBeUndefined(); + }); + + it('should have no tagsQuery property', () => { + expect(model.templating.list[0].tagsQuery).toBeUndefined(); + expect(model.templating.list[1].tagsQuery).toBeUndefined(); + expect(model.templating.list[2].tagsQuery).toBeUndefined(); + }); + + it('should have no tagValuesQuery property', () => { + expect(model.templating.list[0].tagValuesQuery).toBeUndefined(); + expect(model.templating.list[1].tagValuesQuery).toBeUndefined(); + expect(model.templating.list[2].tagValuesQuery).toBeUndefined(); + }); + + it('should have no useTags property', () => { + expect(model.templating.list[0].useTags).toBeUndefined(); + expect(model.templating.list[1].useTags).toBeUndefined(); + expect(model.templating.list[2].useTags).toBeUndefined(); + }); + }); + + describe('when migrating to new Text Panel', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + id: 2, + type: 'text', + title: 'Angular Text Panel', + content: + '# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n', + mode: 'markdown', + }, + { + id: 3, + type: 'text2', + title: 'React Text Panel from scratch', + options: { + mode: 'markdown', + content: + '# React Text Panel from scratch\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text', + }, + }, + { + id: 4, + type: 'text2', + title: 'React Text Panel from Angular Panel', + options: { + mode: 'markdown', + content: + '# React Text Panel from Angular Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text', + angular: { + content: + '# React Text Panel from Angular Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n', + mode: 'markdown', + options: {}, + }, + }, + }, + ], + }); + }); + + it('should have 3 panels after migration', () => { + expect(model.panels.length).toBe(3); + }); + + it('should not migrate panel with old Text Panel id', () => { + const oldAngularPanel: any = model.panels[0]; + expect(oldAngularPanel.id).toEqual(2); + expect(oldAngularPanel.type).toEqual('text'); + expect(oldAngularPanel.title).toEqual('Angular Text Panel'); + expect(oldAngularPanel.content).toEqual( + '# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n' + ); + expect(oldAngularPanel.mode).toEqual('markdown'); + }); + + it('should migrate panels with new Text Panel id', () => { + const reactPanel: any = model.panels[1]; + expect(reactPanel.id).toEqual(3); + expect(reactPanel.type).toEqual('text'); + expect(reactPanel.title).toEqual('React Text Panel from scratch'); + expect(reactPanel.options.content).toEqual( + '# React Text Panel from scratch\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text' + ); + expect(reactPanel.options.mode).toEqual('markdown'); + }); + + it('should clean up old angular options for panels with new Text Panel id', () => { + const reactPanel: any = model.panels[2]; + expect(reactPanel.id).toEqual(4); + expect(reactPanel.type).toEqual('text'); + expect(reactPanel.title).toEqual('React Text Panel from Angular Panel'); + expect(reactPanel.options.content).toEqual( + '# React Text Panel from Angular Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text' + ); + expect(reactPanel.options.mode).toEqual('markdown'); + expect(reactPanel.options.angular).toBeUndefined(); + }); + }); + + describe('when migrating constant variables so they are always hidden', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + type: 'query', + hide: VariableHide.dontHide, + datasource: null, + allFormat: '', + }, + { + type: 'query', + hide: VariableHide.hideLabel, + datasource: null, + allFormat: '', + }, + { + type: 'query', + hide: VariableHide.hideVariable, + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.dontHide, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.hideLabel, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.hideVariable, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + ], + }, + }); + }); + + it('should have six variables after migration', () => { + expect(model.templating.list.length).toBe(6); + }); + + it('should not touch other variable types', () => { + expect(model.templating.list[0].hide).toEqual(VariableHide.dontHide); + expect(model.templating.list[1].hide).toEqual(VariableHide.hideLabel); + expect(model.templating.list[2].hide).toEqual(VariableHide.hideVariable); + }); + + it('should migrate visible constant variables to textbox variables', () => { + expect(model.templating.list[3]).toEqual({ + type: 'textbox', + hide: VariableHide.dontHide, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + expect(model.templating.list[4]).toEqual({ + type: 'textbox', + hide: VariableHide.hideLabel, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + }); + + it('should change current and options for hidden constant variables', () => { + expect(model.templating.list[5]).toEqual({ + type: 'constant', + hide: VariableHide.hideVariable, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + }); + }); + + describe('when migrating variable refresh to on dashboard load', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + type: 'query', + name: 'variable_with_never_refresh_with_options', + options: [{ text: 'A', value: 'A' }], + refresh: 0, + }, + { + type: 'query', + name: 'variable_with_never_refresh_without_options', + options: [], + refresh: 0, + }, + { + type: 'query', + name: 'variable_with_dashboard_refresh_with_options', + options: [{ text: 'A', value: 'A' }], + refresh: 1, + }, + { + type: 'query', + name: 'variable_with_dashboard_refresh_without_options', + options: [], + refresh: 1, + }, + { + type: 'query', + name: 'variable_with_timerange_refresh_with_options', + options: [{ text: 'A', value: 'A' }], + refresh: 2, + }, + { + type: 'query', + name: 'variable_with_timerange_refresh_without_options', + options: [], + refresh: 2, + }, + { + type: 'query', + name: 'variable_with_no_refresh_with_options', + options: [{ text: 'A', value: 'A' }], + }, + { + type: 'query', + name: 'variable_with_no_refresh_without_options', + options: [], + }, + { + type: 'query', + name: 'variable_with_unknown_refresh_with_options', + options: [{ text: 'A', value: 'A' }], + refresh: 2001, + }, + { + type: 'query', + name: 'variable_with_unknown_refresh_without_options', + options: [], + refresh: 2001, + }, + { + type: 'custom', + name: 'custom', + options: [{ text: 'custom', value: 'custom' }], + }, + { + type: 'textbox', + name: 'textbox', + options: [{ text: 'Hello', value: 'World' }], + }, + { + type: 'datasource', + name: 'datasource', + options: [{ text: 'ds', value: 'ds' }], // fake example doesn't exist + }, + { + type: 'interval', + name: 'interval', + options: [{ text: '1m', value: '1m' }], + }, + ], + }, + }); + }); + + it('should have 11 variables after migration', () => { + expect(model.templating.list.length).toBe(14); + }); + + it('should not affect custom variable types', () => { + const custom = model.templating.list[10]; + expect(custom.type).toEqual('custom'); + expect(custom.options).toEqual([{ text: 'custom', value: 'custom' }]); + }); + + it('should not affect textbox variable types', () => { + const textbox = model.templating.list[11]; + expect(textbox.type).toEqual('textbox'); + expect(textbox.options).toEqual([{ text: 'Hello', value: 'World' }]); + }); + + it('should not affect datasource variable types', () => { + const datasource = model.templating.list[12]; + expect(datasource.type).toEqual('datasource'); + expect(datasource.options).toEqual([{ text: 'ds', value: 'ds' }]); + }); + + it('should not affect interval variable types', () => { + const interval = model.templating.list[13]; + expect(interval.type).toEqual('interval'); + expect(interval.options).toEqual([{ text: '1m', value: '1m' }]); + }); + + it('should removed options from all query variables', () => { + const queryVariables = model.templating.list.filter((v) => v.type === 'query'); + expect(queryVariables).toHaveLength(10); + const noOfOptions = queryVariables.reduce((all, variable) => all + variable.options.length, 0); + expect(noOfOptions).toBe(0); + }); + + it('should set the refresh prop to on dashboard load for all query variables that have never or unknown', () => { + expect(model.templating.list[0].refresh).toBe(1); + expect(model.templating.list[1].refresh).toBe(1); + expect(model.templating.list[2].refresh).toBe(1); + expect(model.templating.list[3].refresh).toBe(1); + expect(model.templating.list[4].refresh).toBe(2); + expect(model.templating.list[5].refresh).toBe(2); + expect(model.templating.list[6].refresh).toBe(1); + expect(model.templating.list[7].refresh).toBe(1); + expect(model.templating.list[8].refresh).toBe(1); + expect(model.templating.list[9].refresh).toBe(1); + expect(model.templating.list[10].refresh).toBeUndefined(); + expect(model.templating.list[11].refresh).toBeUndefined(); + expect(model.templating.list[12].refresh).toBeUndefined(); + expect(model.templating.list[13].refresh).toBeUndefined(); + }); + }); + + describe('when migrating old value mapping model', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'timeseries', + fieldConfig: { + defaults: { + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + mappings: [ + { + id: 0, + text: '1', + type: 1, + value: 'up', + }, + { + id: 1, + text: 'BAD', + type: 1, + value: 'down', + }, + { + from: '0', + id: 2, + text: 'below 30', + to: '30', + type: 2, + }, + { + from: '30', + id: 3, + text: '100', + to: '100', + type: 2, + }, + { + type: 1, + value: 'null', + text: 'it is null', + }, + ], + }, + overrides: [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'mappings', + value: [ + { + id: 0, + text: 'OverrideText', + type: 1, + value: 'up', + }, + ], + }, + ], + }, + ], + }, + }, + ], + }); + }); + + it('should migrate value mapping model', () => { + expect(model.panels[0].fieldConfig.defaults.mappings).toEqual([ + { + type: MappingType.ValueToText, + options: { + down: { text: 'BAD', color: undefined }, + up: { text: '1', color: 'green' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 0, + to: 30, + result: { text: 'below 30' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 30, + to: 100, + result: { text: '100', color: 'red' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: 'null', + result: { text: 'it is null', color: undefined }, + }, + }, + ]); + + expect(model.panels[0].fieldConfig.overrides).toEqual([ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'mappings', + value: [ + { + type: MappingType.ValueToText, + options: { + up: { text: 'OverrideText' }, + }, + }, + ], + }, + ], + }, + ]); + }); + }); + + describe('when migrating tooltipOptions to tooltip', () => { + it('should rename options.tooltipOptions to options.tooltip', () => { + const model = new DashboardModel({ + panels: [ + { + type: 'timeseries', + legend: true, + options: { + tooltipOptions: { mode: 'multi' }, + }, + }, + { + type: 'xychart', + legend: true, + options: { + tooltipOptions: { mode: 'single' }, + }, + }, + ], + }); + expect(model.panels[0].options).toMatchInlineSnapshot(` + Object { + "tooltip": Object { + "mode": "multi", + }, + } + `); + expect(model.panels[1].options).toMatchInlineSnapshot(` + Object { + "tooltip": Object { + "mode": "single", + }, + } + `); + }); + }); +}); + +function createRow(options: any, panelDescriptions: any[]) { + const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN; + const { collapse, showTitle, title, repeat, repeatIteration } = options; + let { height } = options; + height = height * PANEL_HEIGHT_STEP; + const panels: any[] = []; + each(panelDescriptions, (panelDesc) => { + const panel = { span: panelDesc[0] }; + if (panelDesc.length > 1) { + //@ts-ignore + panel['height'] = panelDesc[1] * PANEL_HEIGHT_STEP; + } + panels.push(panel); + }); + const row = { + collapse, + height, + showTitle, + title, + panels, + repeat, + repeatIteration, + }; + return row; +} + +function getGridPositions(dashboard: DashboardModel) { + return map(dashboard.panels, (panel: PanelModel) => { + return panel.gridPos; + }); +} diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts new file mode 100644 index 0000000..3d037d0 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -0,0 +1,985 @@ +// Libraries +import { each, find, findIndex, flattenDeep, isArray, isBoolean, isNumber, isString, map, max, some } from 'lodash'; +// Utils +import getFactors from 'app/core/utils/factors'; +import kbn from 'app/core/utils/kbn'; +// Types +import { PanelModel } from './PanelModel'; +import { DashboardModel } from './DashboardModel'; +import { + DataLink, + DataLinkBuiltInVars, + MappingType, + SpecialValueMatch, + PanelPlugin, + standardEditorsRegistry, + standardFieldConfigEditorRegistry, + ThresholdsConfig, + urlUtil, + ValueMap, + ValueMapping, + getActiveThreshold, +} from '@grafana/data'; +// Constants +import { + DEFAULT_PANEL_SPAN, + DEFAULT_ROW_HEIGHT, + GRID_CELL_HEIGHT, + GRID_CELL_VMARGIN, + GRID_COLUMN_COUNT, + MIN_PANEL_HEIGHT, +} from 'app/core/constants'; +import { isConstant, isMulti } from 'app/features/variables/guard'; +import { alignCurrentWithMulti } from 'app/features/variables/shared/multiOptions'; +import { VariableHide } from '../../variables/types'; +import { config } from 'app/core/config'; +import { plugin as statPanelPlugin } from 'app/plugins/panel/stat/module'; +import { plugin as gaugePanelPlugin } from 'app/plugins/panel/gauge/module'; +import { getStandardFieldConfigs, getStandardOptionEditors } from '@grafana/ui'; + +standardEditorsRegistry.setInit(getStandardOptionEditors); +standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); + +export class DashboardMigrator { + dashboard: DashboardModel; + + constructor(dashboardModel: DashboardModel) { + this.dashboard = dashboardModel; + } + + updateSchema(old: any) { + let i, j, k, n; + const oldVersion = this.dashboard.schemaVersion; + const panelUpgrades = []; + this.dashboard.schemaVersion = 30; + + if (oldVersion === this.dashboard.schemaVersion) { + return; + } + + // version 2 schema changes + if (oldVersion < 2) { + if (old.services) { + if (old.services.filter) { + this.dashboard.time = old.services.filter.time; + this.dashboard.templating.list = old.services.filter.list || []; + } + } + + panelUpgrades.push((panel: any) => { + // rename panel type + if (panel.type === 'graphite') { + panel.type = 'graph'; + } + if (panel.type !== 'graph') { + return; + } + + if (isBoolean(panel.legend)) { + panel.legend = { show: panel.legend }; + } + + if (panel.grid) { + if (panel.grid.min) { + panel.grid.leftMin = panel.grid.min; + delete panel.grid.min; + } + + if (panel.grid.max) { + panel.grid.leftMax = panel.grid.max; + delete panel.grid.max; + } + } + + if (panel.y_format) { + if (!panel.y_formats) { + panel.y_formats = []; + } + panel.y_formats[0] = panel.y_format; + delete panel.y_format; + } + + if (panel.y2_format) { + if (!panel.y_formats) { + panel.y_formats = []; + } + panel.y_formats[1] = panel.y2_format; + delete panel.y2_format; + } + }); + } + + // schema version 3 changes + if (oldVersion < 3) { + // ensure panel IDs + let maxId = this.dashboard.getNextPanelId(); + panelUpgrades.push((panel: any) => { + if (!panel.id) { + panel.id = maxId; + maxId += 1; + } + }); + } + + // schema version 4 changes + if (oldVersion < 4) { + // move aliasYAxis changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'graph') { + return; + } + each(panel.aliasYAxis, (value, key) => { + panel.seriesOverrides = [{ alias: key, yaxis: value }]; + }); + delete panel.aliasYAxis; + }); + } + + if (oldVersion < 6) { + // move drop-downs to new schema + const annotations: any = find(old.pulldowns, { type: 'annotations' }); + + if (annotations) { + this.dashboard.annotations = { + list: annotations.annotations || [], + }; + } + + // update template variables + for (i = 0; i < this.dashboard.templating.list.length; i++) { + const variable = this.dashboard.templating.list[i]; + if (variable.datasource === void 0) { + variable.datasource = null; + } + if (variable.type === 'filter') { + variable.type = 'query'; + } + if (variable.type === void 0) { + variable.type = 'query'; + } + if (variable.allFormat === void 0) { + variable.allFormat = 'glob'; + } + } + } + + if (oldVersion < 7) { + if (old.nav && old.nav.length) { + this.dashboard.timepicker = old.nav[0]; + } + + // ensure query refIds + panelUpgrades.push((panel: any) => { + each(panel.targets, (target) => { + if (!target.refId) { + target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter(); + } + }); + }); + } + + if (oldVersion < 8) { + panelUpgrades.push((panel: any) => { + each(panel.targets, (target) => { + // update old influxdb query schema + if (target.fields && target.tags && target.groupBy) { + if (target.rawQuery) { + delete target.fields; + delete target.fill; + } else { + target.select = map(target.fields, (field) => { + const parts = []; + parts.push({ type: 'field', params: [field.name] }); + parts.push({ type: field.func, params: [] }); + if (field.mathExpr) { + parts.push({ type: 'math', params: [field.mathExpr] }); + } + if (field.asExpr) { + parts.push({ type: 'alias', params: [field.asExpr] }); + } + return parts; + }); + delete target.fields; + each(target.groupBy, (part) => { + if (part.type === 'time' && part.interval) { + part.params = [part.interval]; + delete part.interval; + } + if (part.type === 'tag' && part.key) { + part.params = [part.key]; + delete part.key; + } + }); + + if (target.fill) { + target.groupBy.push({ type: 'fill', params: [target.fill] }); + delete target.fill; + } + } + } + }); + }); + } + + // schema version 9 changes + if (oldVersion < 9) { + // move aliasYAxis changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'singlestat' && panel.thresholds !== '') { + return; + } + + if (panel.thresholds) { + const k = panel.thresholds.split(','); + + if (k.length >= 3) { + k.shift(); + panel.thresholds = k.join(','); + } + } + }); + } + + // schema version 10 changes + if (oldVersion < 10) { + // move aliasYAxis changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'table') { + return; + } + + each(panel.styles, (style) => { + if (style.thresholds && style.thresholds.length >= 3) { + const k = style.thresholds; + k.shift(); + style.thresholds = k; + } + }); + }); + } + + if (oldVersion < 12) { + // update template variables + each(this.dashboard.getVariables(), (templateVariable: any) => { + if (templateVariable.refresh) { + templateVariable.refresh = 1; + } + if (!templateVariable.refresh) { + templateVariable.refresh = 0; + } + if (templateVariable.hideVariable) { + templateVariable.hide = 2; + } else if (templateVariable.hideLabel) { + templateVariable.hide = 1; + } + }); + } + + if (oldVersion < 12) { + // update graph yaxes changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'graph') { + return; + } + if (!panel.grid) { + return; + } + + if (!panel.yaxes) { + panel.yaxes = [ + { + show: panel['y-axis'], + min: panel.grid.leftMin, + max: panel.grid.leftMax, + logBase: panel.grid.leftLogBase, + format: panel.y_formats[0], + label: panel.leftYAxisLabel, + }, + { + show: panel['y-axis'], + min: panel.grid.rightMin, + max: panel.grid.rightMax, + logBase: panel.grid.rightLogBase, + format: panel.y_formats[1], + label: panel.rightYAxisLabel, + }, + ]; + + panel.xaxis = { + show: panel['x-axis'], + }; + + delete panel.grid.leftMin; + delete panel.grid.leftMax; + delete panel.grid.leftLogBase; + delete panel.grid.rightMin; + delete panel.grid.rightMax; + delete panel.grid.rightLogBase; + delete panel.y_formats; + delete panel.leftYAxisLabel; + delete panel.rightYAxisLabel; + delete panel['y-axis']; + delete panel['x-axis']; + } + }); + } + + if (oldVersion < 13) { + // update graph yaxes changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'graph') { + return; + } + if (!panel.grid) { + return; + } + + if (!panel.thresholds) { + panel.thresholds = []; + } + const t1: any = {}, + t2: any = {}; + + if (panel.grid.threshold1 !== null) { + t1.value = panel.grid.threshold1; + if (panel.grid.thresholdLine) { + t1.line = true; + t1.lineColor = panel.grid.threshold1Color; + t1.colorMode = 'custom'; + } else { + t1.fill = true; + t1.fillColor = panel.grid.threshold1Color; + t1.colorMode = 'custom'; + } + } + + if (panel.grid.threshold2 !== null) { + t2.value = panel.grid.threshold2; + if (panel.grid.thresholdLine) { + t2.line = true; + t2.lineColor = panel.grid.threshold2Color; + t2.colorMode = 'custom'; + } else { + t2.fill = true; + t2.fillColor = panel.grid.threshold2Color; + t2.colorMode = 'custom'; + } + } + + if (isNumber(t1.value)) { + if (isNumber(t2.value)) { + if (t1.value > t2.value) { + t1.op = t2.op = 'lt'; + panel.thresholds.push(t1); + panel.thresholds.push(t2); + } else { + t1.op = t2.op = 'gt'; + panel.thresholds.push(t1); + panel.thresholds.push(t2); + } + } else { + t1.op = 'gt'; + panel.thresholds.push(t1); + } + } + + delete panel.grid.threshold1; + delete panel.grid.threshold1Color; + delete panel.grid.threshold2; + delete panel.grid.threshold2Color; + delete panel.grid.thresholdLine; + }); + } + + if (oldVersion < 14) { + this.dashboard.graphTooltip = old.sharedCrosshair ? 1 : 0; + } + + if (oldVersion < 16) { + this.upgradeToGridLayout(old); + } + + if (oldVersion < 17) { + panelUpgrades.push((panel: any) => { + if (panel.minSpan) { + const max = GRID_COLUMN_COUNT / panel.minSpan; + const factors = getFactors(GRID_COLUMN_COUNT); + // find the best match compared to factors + // (ie. [1,2,3,4,6,12,24] for 24 columns) + panel.maxPerRow = + factors[ + findIndex(factors, (o) => { + return o > max; + }) - 1 + ]; + } + delete panel.minSpan; + }); + } + + if (oldVersion < 18) { + // migrate change to gauge options + panelUpgrades.push((panel: any) => { + if (panel['options-gauge']) { + panel.options = panel['options-gauge']; + panel.options.valueOptions = { + unit: panel.options.unit, + stat: panel.options.stat, + decimals: panel.options.decimals, + prefix: panel.options.prefix, + suffix: panel.options.suffix, + }; + + // correct order + if (panel.options.thresholds) { + panel.options.thresholds.reverse(); + } + + // this options prop was due to a bug + delete panel.options.options; + delete panel.options.unit; + delete panel.options.stat; + delete panel.options.decimals; + delete panel.options.prefix; + delete panel.options.suffix; + delete panel['options-gauge']; + } + }); + } + + if (oldVersion < 19) { + // migrate change to gauge options + panelUpgrades.push((panel: any) => { + if (panel.links && isArray(panel.links)) { + panel.links = panel.links.map(upgradePanelLink); + } + }); + } + + if (oldVersion < 20) { + const updateLinks = (link: DataLink) => { + return { + ...link, + url: updateVariablesSyntax(link.url), + }; + }; + panelUpgrades.push((panel: any) => { + // For graph panel + if (panel.options && panel.options.dataLinks && isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + + // For panel with fieldOptions + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (panel.options.fieldOptions.defaults.links && isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + if (panel.options.fieldOptions.defaults.title) { + panel.options.fieldOptions.defaults.title = updateVariablesSyntax( + panel.options.fieldOptions.defaults.title + ); + } + } + }); + } + + if (oldVersion < 21) { + const updateLinks = (link: DataLink) => { + return { + ...link, + url: link.url.replace(/__series.labels/g, '__field.labels'), + }; + }; + panelUpgrades.push((panel: any) => { + // For graph panel + if (panel.options && panel.options.dataLinks && isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + + // For panel with fieldOptions + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (panel.options.fieldOptions.defaults.links && isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + } + }); + } + + if (oldVersion < 22) { + panelUpgrades.push((panel: any) => { + if (panel.type !== 'table') { + return; + } + + each(panel.styles, (style) => { + style.align = 'auto'; + }); + }); + } + + if (oldVersion < 23) { + for (const variable of this.dashboard.templating.list) { + if (!isMulti(variable)) { + continue; + } + const { multi, current } = variable; + variable.current = alignCurrentWithMulti(current, multi); + } + } + + if (oldVersion < 24) { + // 7.0 + // - migrate existing tables to 'table-old' + panelUpgrades.push((panel: any) => { + const wasAngularTable = panel.type === 'table'; + if (wasAngularTable && !panel.styles) { + return; // styles are missing so assumes default settings + } + const wasReactTable = panel.table === 'table2'; + if (!wasAngularTable || wasReactTable) { + return; + } + panel.type = wasAngularTable ? 'table-old' : 'table'; + }); + } + + if (oldVersion < 25) { + // tags are removed in version 28 + } + + if (oldVersion < 26) { + panelUpgrades.push((panel: any) => { + const wasReactText = panel.type === 'text2'; + if (!wasReactText) { + return; + } + + panel.type = 'text'; + delete panel.options.angular; + }); + } + + if (oldVersion < 27) { + for (const variable of this.dashboard.templating.list) { + if (!isConstant(variable)) { + continue; + } + + if (variable.hide === VariableHide.dontHide || variable.hide === VariableHide.hideLabel) { + variable.type = 'textbox'; + } + + variable.current = { selected: true, text: variable.query ?? '', value: variable.query ?? '' }; + variable.options = [variable.current]; + } + } + + if (oldVersion < 28) { + panelUpgrades.push((panel: PanelModel) => { + if (panel.type === 'singlestat') { + migrateSinglestat(panel); + } + }); + + for (const variable of this.dashboard.templating.list) { + if (variable.tags) { + delete variable.tags; + } + + if (variable.tagsQuery) { + delete variable.tagsQuery; + } + + if (variable.tagValuesQuery) { + delete variable.tagValuesQuery; + } + + if (variable.useTags) { + delete variable.useTags; + } + } + } + + if (oldVersion < 29) { + for (const variable of this.dashboard.templating.list) { + if (variable.type !== 'query') { + continue; + } + + if (variable.refresh !== 1 && variable.refresh !== 2) { + variable.refresh = 1; + } + + if (variable.options?.length) { + variable.options = []; + } + } + } + + if (oldVersion < 30) { + panelUpgrades.push(upgradeValueMappingsForPanel); + panelUpgrades.push(migrateTooltipOptions); + } + + if (panelUpgrades.length === 0) { + return; + } + + for (j = 0; j < this.dashboard.panels.length; j++) { + for (k = 0; k < panelUpgrades.length; k++) { + panelUpgrades[k].call(this, this.dashboard.panels[j]); + if (this.dashboard.panels[j].panels) { + for (n = 0; n < this.dashboard.panels[j].panels.length; n++) { + panelUpgrades[k].call(this, this.dashboard.panels[j].panels[n]); + } + } + } + } + } + + upgradeToGridLayout(old: any) { + let yPos = 0; + const widthFactor = GRID_COLUMN_COUNT / 12; + + const maxPanelId = max( + flattenDeep( + map(old.rows, (row) => { + return map(row.panels, 'id'); + }) + ) + ); + let nextRowId = maxPanelId + 1; + + if (!old.rows) { + return; + } + + // Add special "row" panels if even one row is collapsed, repeated or has visible title + const showRows = some(old.rows, (row) => row.collapse || row.showTitle || row.repeat); + + for (const row of old.rows) { + if (row.repeatIteration) { + continue; + } + + const height: any = row.height || DEFAULT_ROW_HEIGHT; + const rowGridHeight = getGridHeight(height); + + const rowPanel: any = {}; + let rowPanelModel: PanelModel | undefined; + + if (showRows) { + // add special row panel + rowPanel.id = nextRowId; + rowPanel.type = 'row'; + rowPanel.title = row.title; + rowPanel.collapsed = row.collapse; + rowPanel.repeat = row.repeat; + rowPanel.panels = []; + rowPanel.gridPos = { + x: 0, + y: yPos, + w: GRID_COLUMN_COUNT, + h: rowGridHeight, + }; + rowPanelModel = new PanelModel(rowPanel); + nextRowId++; + yPos++; + } + + const rowArea = new RowArea(rowGridHeight, GRID_COLUMN_COUNT, yPos); + + for (const panel of row.panels) { + panel.span = panel.span || DEFAULT_PANEL_SPAN; + if (panel.minSpan) { + panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan); + } + const panelWidth = Math.floor(panel.span) * widthFactor; + const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight; + + const panelPos = rowArea.getPanelPosition(panelHeight, panelWidth); + yPos = rowArea.yPos; + panel.gridPos = { + x: panelPos.x, + y: yPos + panelPos.y, + w: panelWidth, + h: panelHeight, + }; + rowArea.addPanel(panel.gridPos); + + delete panel.span; + + if (rowPanelModel && rowPanel.collapsed) { + rowPanelModel.panels.push(panel); + } else { + this.dashboard.panels.push(new PanelModel(panel)); + } + } + + if (rowPanelModel) { + this.dashboard.panels.push(rowPanelModel); + } + + if (!(rowPanelModel && rowPanel.collapsed)) { + yPos += rowGridHeight; + } + } + } +} + +function getGridHeight(height: number | string) { + if (isString(height)) { + height = parseInt(height.replace('px', ''), 10); + } + + if (height < MIN_PANEL_HEIGHT) { + height = MIN_PANEL_HEIGHT; + } + + const gridHeight = Math.ceil(height / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); + return gridHeight; +} + +/** + * RowArea represents dashboard row filled by panels + * area is an array of numbers represented filled column's cells like + * ----------------------- + * |******** **** + * |******** **** + * |******** + * ----------------------- + * 33333333 2222 00000 ... + */ +class RowArea { + area: number[]; + yPos: number; + height: number; + + constructor(height: number, width = GRID_COLUMN_COUNT, rowYPos = 0) { + this.area = new Array(width).fill(0); + this.yPos = rowYPos; + this.height = height; + } + + reset() { + this.area.fill(0); + } + + /** + * Update area after adding the panel. + */ + addPanel(gridPos: any) { + for (let i = gridPos.x; i < gridPos.x + gridPos.w; i++) { + if (!this.area[i] || gridPos.y + gridPos.h - this.yPos > this.area[i]) { + this.area[i] = gridPos.y + gridPos.h - this.yPos; + } + } + return this.area; + } + + /** + * Calculate position for the new panel in the row. + */ + getPanelPosition(panelHeight: number, panelWidth: number, callOnce = false): any { + let startPlace, endPlace; + let place; + for (let i = this.area.length - 1; i >= 0; i--) { + if (this.height - this.area[i] > 0) { + if (endPlace === undefined) { + endPlace = i; + } else { + if (i < this.area.length - 1 && this.area[i] <= this.area[i + 1]) { + startPlace = i; + } else { + break; + } + } + } else { + break; + } + } + + if (startPlace !== undefined && endPlace !== undefined && endPlace - startPlace >= panelWidth - 1) { + const yPos = max(this.area.slice(startPlace)); + place = { + x: startPlace, + y: yPos, + }; + } else if (!callOnce) { + // wrap to next row + this.yPos += this.height; + this.reset(); + return this.getPanelPosition(panelHeight, panelWidth, true); + } else { + return null; + } + + return place; + } +} + +function upgradePanelLink(link: any): DataLink { + let url = link.url; + + if (!url && link.dashboard) { + url = `dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`; + } + + if (!url && link.dashUri) { + url = `dashboard/${link.dashUri}`; + } + + // some models are incomplete and have no dashboard or dashUri + if (!url) { + url = '/'; + } + + if (link.keepTime) { + url = urlUtil.appendQueryToUrl(url, `$${DataLinkBuiltInVars.keepTime}`); + } + + if (link.includeVars) { + url = urlUtil.appendQueryToUrl(url, `$${DataLinkBuiltInVars.includeVars}`); + } + + if (link.params) { + url = urlUtil.appendQueryToUrl(url, link.params); + } + + return { + url: url, + title: link.title, + targetBlank: link.targetBlank, + }; +} + +function updateVariablesSyntax(text: string) { + const legacyVariableNamesRegex = /(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)/g; + + return text.replace(legacyVariableNamesRegex, (match, seriesName, seriesName1, valueTime, fieldName, fieldName1) => { + if (seriesName) { + return '__series.name'; + } + if (seriesName1) { + return '${__series.name}'; + } + if (valueTime) { + return '__value.time'; + } + if (fieldName) { + return '__field.name'; + } + if (fieldName1) { + return '${__field.name}'; + } + return match; + }); +} + +function migrateSinglestat(panel: PanelModel) { + // If 'grafana-singlestat-panel' exists, move to that + if (config.panels['grafana-singlestat-panel']) { + panel.type = 'grafana-singlestat-panel'; + return; + } + + // To make sure PanelModel.isAngularPlugin logic thinks the current panel is angular + // And since this plugin no longer exist we just fake it here + panel.plugin = { angularPanelCtrl: {} } as PanelPlugin; + + // Otheriwse use gauge or stat panel + if ((panel as any).gauge?.show) { + gaugePanelPlugin.meta = config.panels['gauge']; + panel.changePlugin(gaugePanelPlugin); + } else { + statPanelPlugin.meta = config.panels['stat']; + panel.changePlugin(statPanelPlugin); + } +} + +function upgradeValueMappingsForPanel(panel: PanelModel) { + const fieldConfig = panel.fieldConfig; + if (!fieldConfig) { + return; + } + + fieldConfig.defaults.mappings = upgradeValueMappings(fieldConfig.defaults.mappings, fieldConfig.defaults.thresholds); + + for (const override of fieldConfig.overrides) { + for (const prop of override.properties) { + if (prop.id === 'mappings') { + prop.value = upgradeValueMappings(prop.value); + } + } + } +} + +function upgradeValueMappings(oldMappings: any, thresholds?: ThresholdsConfig): ValueMapping[] | undefined { + if (!oldMappings) { + return undefined; + } + + const valueMaps: ValueMap = { type: MappingType.ValueToText, options: {} }; + const newMappings: ValueMapping[] = []; + + for (const old of oldMappings) { + // Use the color we would have picked from thesholds + let color: string | undefined = undefined; + const numeric = parseFloat(old.text); + if (thresholds && !isNaN(numeric)) { + const level = getActiveThreshold(numeric, thresholds.steps); + if (level && level.color) { + color = level.color; + } + } + + switch (old.type) { + case 1: // MappingType.ValueToText: + if (old.value != null) { + if (old.value === 'null') { + newMappings.push({ + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: old.text, color }, + }, + }); + } else { + valueMaps.options[String(old.value)] = { + text: old.text, + color, + }; + } + } + break; + case 2: // MappingType.RangeToText: + newMappings.push({ + type: MappingType.RangeToText, + options: { + from: +old.from, + to: +old.to, + result: { text: old.text, color }, + }, + }); + break; + } + } + + if (Object.keys(valueMaps.options).length > 0) { + newMappings.unshift(valueMaps); + } + + return newMappings; +} + +function migrateTooltipOptions(panel: PanelModel) { + if (panel.type === 'timeseries' || panel.type === 'xychart') { + if (panel.options.tooltipOptions) { + panel.options = { + ...panel.options, + tooltip: panel.options.tooltipOptions, + }; + delete panel.options.tooltipOptions; + } + } +} diff --git a/public/app/features/dashboard/state/DashboardModel.repeat.test.ts b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts new file mode 100644 index 0000000..3bf4355 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts @@ -0,0 +1,741 @@ +import { compact, flattenDeep, map, uniq } from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { expect } from 'test/lib/common'; +import { getDashboardModel } from '../../../../test/helpers/getDashboardModel'; +import { PanelModel } from './PanelModel'; + +jest.mock('app/core/services/context_srv', () => ({})); + +describe('given dashboard with panel repeat', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } }, + ], + templating: { + list: [ + { + name: 'apps', + type: 'custom', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should repeat panels when row is expanding', () => { + expect(dashboard.panels.length).toBe(4); + + // toggle row + dashboard.toggleRow(dashboard.panels[0]); + expect(dashboard.panels.length).toBe(1); + + // change variable + dashboard.templating.list[0].options[2].selected = false; + dashboard.templating.list[0].current = { + text: 'se1, se2', + value: ['se1', 'se2'], + }; + + // toggle row back + dashboard.toggleRow(dashboard.panels[0]); + expect(dashboard.panels.length).toBe(3); + }); +}); + +describe('given dashboard with panel repeat in horizontal direction', () => { + let dashboard: any; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { + id: 2, + repeat: 'apps', + repeatDirection: 'h', + gridPos: { x: 0, y: 0, h: 2, w: 24 }, + }, + ], + templating: { + list: [ + { + name: 'apps', + type: 'custom', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should repeat panel 3 times', () => { + expect(dashboard.panels.length).toBe(3); + }); + + it('should mark panel repeated', () => { + expect(dashboard.panels[0].repeat).toBe('apps'); + expect(dashboard.panels[1].repeatPanelId).toBe(2); + }); + + it('should set scopedVars on panels', () => { + expect(dashboard.panels[0].scopedVars.apps.value).toBe('se1'); + expect(dashboard.panels[1].scopedVars.apps.value).toBe('se2'); + expect(dashboard.panels[2].scopedVars.apps.value).toBe('se3'); + }); + + it('should place on first row and adjust width so all fit', () => { + expect(dashboard.panels[0].gridPos).toMatchObject({ + x: 0, + y: 0, + h: 2, + w: 8, + }); + expect(dashboard.panels[1].gridPos).toMatchObject({ + x: 8, + y: 0, + h: 2, + w: 8, + }); + expect(dashboard.panels[2].gridPos).toMatchObject({ + x: 16, + y: 0, + h: 2, + w: 8, + }); + }); + + describe('After a second iteration', () => { + beforeEach(() => { + dashboard.panels[0].fill = 10; + dashboard.processRepeats(); + }); + + it('reused panel should copy properties from source', () => { + expect(dashboard.panels[1].fill).toBe(10); + }); + + it('should have same panel count', () => { + expect(dashboard.panels.length).toBe(3); + }); + }); + + describe('After a second iteration with different variable', () => { + beforeEach(() => { + dashboard.templating.list.push({ + name: 'server', + current: { text: 'se1, se2, se3', value: ['se1'] }, + options: [{ text: 'se1', value: 'se1', selected: true }], + }); + dashboard.panels[0].repeat = 'server'; + dashboard.processRepeats(); + }); + + it('should remove scopedVars value for last variable', () => { + expect(dashboard.panels[0].scopedVars.apps).toBe(undefined); + }); + + it('should have new variable value in scopedVars', () => { + expect(dashboard.panels[0].scopedVars.server.value).toBe('se1'); + }); + }); + + describe('After a second iteration and selected values reduced', () => { + beforeEach(() => { + dashboard.templating.list[0].options[1].selected = false; + dashboard.processRepeats(); + }); + + it('should clean up repeated panel', () => { + expect(dashboard.panels.length).toBe(2); + }); + }); + + describe('After a second iteration and panel repeat is turned off', () => { + beforeEach(() => { + dashboard.panels[0].repeat = null; + dashboard.processRepeats(); + }); + + it('should clean up repeated panel', () => { + expect(dashboard.panels.length).toBe(1); + }); + + it('should remove scoped vars from reused panel', () => { + expect(dashboard.panels[0].scopedVars).toBe(undefined); + }); + }); +}); + +describe('given dashboard with panel repeat in vertical direction', () => { + let dashboard: any; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'apps', repeatDirection: 'v', gridPos: { x: 5, y: 1, h: 2, w: 8 } }, + { id: 3, type: 'row', gridPos: { x: 0, y: 3, h: 1, w: 24 } }, + ], + templating: { + list: [ + { + name: 'apps', + type: 'custom', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should place on items on top of each other and keep witdh', () => { + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // first row + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 5, y: 1, h: 2, w: 8 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 5, y: 3, h: 2, w: 8 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 5, y: 5, h: 2, w: 8 }); + + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 0, y: 7, h: 1, w: 24 }); // last row + }); +}); + +describe('given dashboard with row repeat and panel repeat in horizontal direction', () => { + let dashboard: any, dashboardJSON: any; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { id: 1, type: 'row', repeat: 'region', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 2, w: 6 } }, + ], + templating: { + list: [ + { + name: 'region', + type: 'custom', + current: { + text: 'reg1, reg2', + value: ['reg1', 'reg2'], + }, + options: [ + { text: 'reg1', value: 'reg1', selected: true }, + { text: 'reg2', value: 'reg2', selected: true }, + ], + }, + { + name: 'app', + type: 'custom', + current: { + text: 'se1, se2, se3, se4, se5, se6', + value: ['se1', 'se2', 'se3', 'se4', 'se5', 'se6'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: true }, + { text: 'se5', value: 'se5', selected: true }, + { text: 'se6', value: 'se6', selected: true }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(false); + }); + + it('should panels in self row', () => { + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual([ + 'row', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + ]); + }); + + it('should be placed in their places', () => { + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // 1st row + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 0, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 6, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 12, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 18, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[5].gridPos).toMatchObject({ x: 0, y: 3, h: 2, w: 6 }); // next row + expect(dashboard.panels[6].gridPos).toMatchObject({ x: 6, y: 3, h: 2, w: 6 }); + + expect(dashboard.panels[7].gridPos).toMatchObject({ x: 0, y: 5, h: 1, w: 24 }); + + expect(dashboard.panels[8].gridPos).toMatchObject({ x: 0, y: 6, h: 2, w: 6 }); // 2nd row + expect(dashboard.panels[9].gridPos).toMatchObject({ x: 6, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[10].gridPos).toMatchObject({ x: 12, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[11].gridPos).toMatchObject({ x: 18, y: 6, h: 2, w: 6 }); // next row + expect(dashboard.panels[12].gridPos).toMatchObject({ x: 0, y: 8, h: 2, w: 6 }); + expect(dashboard.panels[13].gridPos).toMatchObject({ x: 6, y: 8, h: 2, w: 6 }); + }); +}); + +describe('given dashboard with row repeat', () => { + let dashboard: any, dashboardJSON: any; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { + id: 1, + type: 'row', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + repeat: 'apps', + }, + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + { id: 4, type: 'row', gridPos: { x: 0, y: 2, h: 1, w: 24 } }, + { id: 5, type: 'graph', gridPos: { x: 0, y: 3, h: 1, w: 12 } }, + ], + templating: { + list: [ + { + name: 'apps', + type: 'custom', + current: { + text: 'se1, se2', + value: ['se1', 'se2'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should not repeat only row', () => { + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph', 'row', 'graph']); + }); + + it('should set scopedVars for each panel', () => { + dashboardJSON.templating.list[0].options[2].selected = true; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[1].scopedVars).toMatchObject({ + apps: { text: 'se1', value: 'se1' }, + }); + expect(dashboard.panels[4].scopedVars).toMatchObject({ + apps: { text: 'se2', value: 'se2' }, + }); + + const scopedVars = compact( + map(dashboard.panels, (panel) => { + return panel.scopedVars ? panel.scopedVars.apps.value : null; + }) + ); + + expect(scopedVars).toEqual(['se1', 'se1', 'se1', 'se2', 'se2', 'se2', 'se3', 'se3', 'se3']); + }); + + it('should repeat only configured row', () => { + expect(dashboard.panels[6].id).toBe(4); + expect(dashboard.panels[7].id).toBe(5); + }); + + it('should repeat only row if it is collapsed', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + collapsed: true, + repeat: 'apps', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + panels: [ + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + ], + }, + { id: 4, type: 'row', gridPos: { x: 0, y: 1, h: 1, w: 24 } }, + { id: 5, type: 'graph', gridPos: { x: 0, y: 2, h: 1, w: 12 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'row', 'row', 'graph']); + expect(dashboard.panels[0].panels).toHaveLength(2); + expect(dashboard.panels[1].panels).toHaveLength(2); + }); + + it('should properly repeat multiple rows', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + repeat: 'apps', + }, // repeat + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + { id: 4, type: 'row', gridPos: { x: 0, y: 2, h: 1, w: 24 } }, // don't touch + { id: 5, type: 'graph', gridPos: { x: 0, y: 3, h: 1, w: 12 } }, + { + id: 6, + type: 'row', + gridPos: { x: 0, y: 4, h: 1, w: 24 }, + repeat: 'hosts', + }, // repeat + { id: 7, type: 'graph', gridPos: { x: 0, y: 5, h: 1, w: 6 } }, + { id: 8, type: 'graph', gridPos: { x: 6, y: 5, h: 1, w: 6 } }, + ]; + dashboardJSON.templating.list.push({ + name: 'hosts', + type: 'custom', + current: { + text: 'backend01, backend02', + value: ['backend01', 'backend02'], + }, + options: [ + { text: 'backend01', value: 'backend01', selected: true }, + { text: 'backend02', value: 'backend02', selected: true }, + { text: 'backend03', value: 'backend03', selected: false }, + ], + }); + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual([ + 'row', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + 'row', + 'graph', + 'row', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + ]); + + expect(dashboard.panels[0].scopedVars['apps'].value).toBe('se1'); + expect(dashboard.panels[1].scopedVars['apps'].value).toBe('se1'); + expect(dashboard.panels[3].scopedVars['apps'].value).toBe('se2'); + expect(dashboard.panels[4].scopedVars['apps'].value).toBe('se2'); + expect(dashboard.panels[8].scopedVars['hosts'].value).toBe('backend01'); + expect(dashboard.panels[9].scopedVars['hosts'].value).toBe('backend01'); + expect(dashboard.panels[11].scopedVars['hosts'].value).toBe('backend02'); + expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02'); + }); + + it('should assign unique ids for repeated panels', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + collapsed: true, + repeat: 'apps', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + panels: [ + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + ], + }, + { id: 4, type: 'row', gridPos: { x: 0, y: 1, h: 1, w: 24 } }, + { id: 5, type: 'graph', gridPos: { x: 0, y: 2, h: 1, w: 12 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelIds = flattenDeep( + map(dashboard.panels, (panel) => { + let ids = []; + if (panel.panels && panel.panels.length) { + ids = map(panel.panels, 'id'); + } + ids.push(panel.id); + return ids; + }) + ); + expect(panelIds.length).toEqual(uniq(panelIds).length); + }); + + it('should place new panels in proper order', () => { + dashboardJSON.panels = [ + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 }, repeat: 'apps' }, + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 3, w: 12 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 4, w: 12 } }, + { id: 4, type: 'graph', gridPos: { x: 0, y: 5, h: 2, w: 12 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'graph', 'row', 'graph', 'graph', 'graph']); + const panelYPositions = map(dashboard.panels, (p) => p.gridPos.y); + expect(panelYPositions).toEqual([0, 1, 1, 5, 7, 8, 8, 12]); + }); +}); + +describe('given dashboard with row and panel repeat', () => { + let dashboard: any, dashboardJSON: any; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { + id: 1, + type: 'row', + repeat: 'region', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + ], + templating: { + list: [ + { + name: 'region', + type: 'custom', + current: { + text: 'reg1, reg2', + value: ['reg1', 'reg2'], + }, + options: [ + { text: 'reg1', value: 'reg1', selected: true }, + { text: 'reg2', value: 'reg2', selected: true }, + { text: 'reg3', value: 'reg3', selected: false }, + ], + }, + { + name: 'app', + type: 'custom', + current: { + text: 'se1, se2', + value: ['se1', 'se2'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should repeat row and panels for each row', () => { + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']); + }); + + it('should clean up old repeated panels', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + repeat: 'region', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', repeatPanelId: 2, repeatIteration: 101, gridPos: { x: 7, y: 1, h: 1, w: 6 } }, + { + id: 11, + type: 'row', + repeatPanelId: 1, + repeatIteration: 101, + gridPos: { x: 0, y: 2, h: 1, w: 24 }, + }, + { id: 12, type: 'graph', repeatPanelId: 2, repeatIteration: 101, gridPos: { x: 0, y: 3, h: 1, w: 6 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']); + }); + + it('should set scopedVars for each row', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[0].scopedVars).toMatchObject({ + region: { text: 'reg1', value: 'reg1' }, + }); + expect(dashboard.panels[3].scopedVars).toMatchObject({ + region: { text: 'reg2', value: 'reg2' }, + }); + }); + + it('should set panel-repeat variable for each panel', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[1].scopedVars).toMatchObject({ + app: { text: 'se1', value: 'se1' }, + }); + expect(dashboard.panels[2].scopedVars).toMatchObject({ + app: { text: 'se2', value: 'se2' }, + }); + + expect(dashboard.panels[4].scopedVars).toMatchObject({ + app: { text: 'se1', value: 'se1' }, + }); + expect(dashboard.panels[5].scopedVars).toMatchObject({ + app: { text: 'se2', value: 'se2' }, + }); + }); + + it('should set row-repeat variable for each panel', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[1].scopedVars).toMatchObject({ + region: { text: 'reg1', value: 'reg1' }, + }); + expect(dashboard.panels[2].scopedVars).toMatchObject({ + region: { text: 'reg1', value: 'reg1' }, + }); + + expect(dashboard.panels[4].scopedVars).toMatchObject({ + region: { text: 'reg2', value: 'reg2' }, + }); + expect(dashboard.panels[5].scopedVars).toMatchObject({ + region: { text: 'reg2', value: 'reg2' }, + }); + }); + + it('should repeat panels when row is expanding', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels.length).toBe(6); + + // toggle row + dashboard.toggleRow(dashboard.panels[0]); + dashboard.toggleRow(dashboard.panels[1]); + expect(dashboard.panels.length).toBe(2); + + // change variable + dashboard.templating.list[1].current.value = ['se1', 'se2', 'se3']; + + // toggle row back + dashboard.toggleRow(dashboard.panels[1]); + expect(dashboard.panels.length).toBe(4); + }); +}); + +describe('given panel is in view mode', () => { + let dashboard: any; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { + id: 1, + repeat: 'apps', + repeatDirection: 'h', + gridPos: { x: 0, y: 0, h: 2, w: 24 }, + }, + ], + templating: { + list: [ + { + name: 'apps', + type: 'custom', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + + dashboard = getDashboardModel(dashboardJSON); + dashboard.initViewPanel( + new PanelModel({ + id: 2, + repeat: undefined, + repeatDirection: 'h', + panels: [ + { + id: 2, + repeat: 'apps', + repeatDirection: 'h', + gridPos: { x: 0, y: 0, h: 2, w: 24 }, + }, + ], + repeatPanelId: 2, + }) + ); + dashboard.processRepeats(); + }); + + it('should set correct repeated panel to be in view', () => { + expect(dashboard.panels[1].isViewing).toBeTruthy(); + }); +}); diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts new file mode 100644 index 0000000..ae16f89 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -0,0 +1,766 @@ +import { keys as _keys } from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { PanelModel } from '../state/PanelModel'; +import { getDashboardModel } from '../../../../test/helpers/getDashboardModel'; +import { variableAdapters } from '../../variables/adapters'; +import { createAdHocVariableAdapter } from '../../variables/adhoc/adapter'; +import { createQueryVariableAdapter } from '../../variables/query/adapter'; +import { createCustomVariableAdapter } from '../../variables/custom/adapter'; + +jest.mock('app/core/services/context_srv', () => ({})); +variableAdapters.setInit(() => [ + createQueryVariableAdapter(), + createAdHocVariableAdapter(), + createCustomVariableAdapter(), +]); + +describe('DashboardModel', () => { + describe('when creating new dashboard model defaults only', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({}, {}); + }); + + it('should have title', () => { + expect(model.title).toBe('No Title'); + }); + + it('should have meta', () => { + expect(model.meta.canSave).toBe(true); + expect(model.meta.canShare).toBe(true); + }); + + it('should have default properties', () => { + expect(model.panels.length).toBe(0); + }); + }); + + describe('when getting next panel id', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + panels: [{ id: 5 }], + }); + }); + + it('should return max id + 1', () => { + expect(model.getNextPanelId()).toBe(6); + }); + }); + + describe('getSaveModelClone', () => { + it('should sort keys', () => { + const model = new DashboardModel({}); + const saveModel = model.getSaveModelClone(); + const keys = _keys(saveModel); + + expect(keys[0]).toBe('annotations'); + expect(keys[1]).toBe('autoUpdate'); + }); + + it('should remove add panel panels', () => { + const model = new DashboardModel({}); + model.addPanel({ + type: 'add-panel', + }); + model.addPanel({ + type: 'graph', + }); + model.addPanel({ + type: 'add-panel', + }); + const saveModel = model.getSaveModelClone(); + const panels = saveModel.panels; + + expect(panels.length).toBe(1); + }); + + it('should save model in edit mode', () => { + const model = new DashboardModel({}); + model.addPanel({ type: 'graph' }); + + const panel = model.initEditPanel(model.panels[0]); + panel.title = 'updated'; + + const saveModel = model.getSaveModelClone(); + const savedPanel = saveModel.panels[0]; + + expect(savedPanel.title).toBe('updated'); + expect(savedPanel.id).toBe(model.panels[0].id); + }); + }); + + describe('row and panel manipulation', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({}); + }); + + it('adding panel should new up panel model', () => { + dashboard.addPanel({ type: 'test', title: 'test' }); + + expect(dashboard.panels[0] instanceof PanelModel).toBe(true); + }); + + it('duplicate panel should try to add to the right if there is space', () => { + const panel = { id: 10, gridPos: { x: 0, y: 0, w: 6, h: 2 } }; + + dashboard.addPanel(panel); + dashboard.duplicatePanel(dashboard.panels[0]); + + expect(dashboard.panels[1].gridPos).toMatchObject({ + x: 6, + y: 0, + h: 2, + w: 6, + }); + }); + + it('duplicate panel should remove repeat data', () => { + const panel = { + id: 10, + gridPos: { x: 0, y: 0, w: 6, h: 2 }, + repeat: 'asd', + scopedVars: { test: 'asd' }, + }; + + dashboard.addPanel(panel); + dashboard.duplicatePanel(dashboard.panels[0]); + + expect(dashboard.panels[1].repeat).toBe(undefined); + expect(dashboard.panels[1].scopedVars).toBe(undefined); + }); + }); + + describe('Given editable false dashboard', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ editable: false }); + }); + + it('Should set meta canEdit and canSave to false', () => { + expect(model.meta.canSave).toBe(false); + expect(model.meta.canEdit).toBe(false); + }); + + it('getSaveModelClone should remove meta', () => { + const clone = model.getSaveModelClone(); + expect(clone.meta).toBe(undefined); + }); + }); + + describe('when loading dashboard with old influxdb query schema', () => { + let model: DashboardModel; + let target: any; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + type: 'graph', + grid: {}, + yaxes: [{}, {}], + targets: [ + { + alias: '$tag_datacenter $tag_source $col', + column: 'value', + measurement: 'logins.count', + fields: [ + { + func: 'mean', + name: 'value', + mathExpr: '*2', + asExpr: 'value', + }, + { + name: 'one-minute', + func: 'mean', + mathExpr: '*3', + asExpr: 'one-minute', + }, + ], + tags: [], + fill: 'previous', + function: 'mean', + groupBy: [ + { + interval: 'auto', + type: 'time', + }, + { + key: 'source', + type: 'tag', + }, + { + type: 'tag', + key: 'datacenter', + }, + ], + }, + ], + }, + ], + }); + + target = model.panels[0].targets[0]; + }); + + it('should update query schema', () => { + expect(target.fields).toBe(undefined); + expect(target.select.length).toBe(2); + expect(target.select[0].length).toBe(4); + expect(target.select[0][0].type).toBe('field'); + expect(target.select[0][1].type).toBe('mean'); + expect(target.select[0][2].type).toBe('math'); + expect(target.select[0][3].type).toBe('alias'); + }); + }); + + describe('when creating dashboard model with missing list for annoations or templating', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + annotations: { + enable: true, + }, + templating: { + enable: true, + }, + }); + }); + + it('should add empty list', () => { + expect(model.annotations.list.length).toBe(1); + expect(model.templating.list.length).toBe(0); + }); + + it('should add builtin annotation query', () => { + expect(model.annotations.list[0].builtIn).toBe(1); + expect(model.templating.list.length).toBe(0); + }); + }); + + describe('Formatting epoch timestamp when timezone is set as utc', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ timezone: 'utc' }); + }); + + it('Should format timestamp with second resolution by default', () => { + expect(dashboard.formatDate(1234567890000)).toBe('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with second resolution even if second format is passed as parameter', () => { + expect(dashboard.formatDate(1234567890007, 'YYYY-MM-DD HH:mm:ss')).toBe('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with millisecond resolution if format is passed as parameter', () => { + expect(dashboard.formatDate(1234567890007, 'YYYY-MM-DD HH:mm:ss.SSS')).toBe('2009-02-13 23:31:30.007'); + }); + }); + + describe('isSubMenuVisible with empty lists', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({}); + }); + + it('should not show submenu', () => { + expect(model.isSubMenuVisible()).toBe(false); + }); + }); + + describe('isSubMenuVisible with annotation', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + annotations: { + list: [{}], + }, + }); + }); + + it('should show submmenu', () => { + expect(model.isSubMenuVisible()).toBe(true); + }); + }); + + describe('isSubMenuVisible with template var', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel( + { + templating: { + list: [{}], + }, + }, + {}, + // getVariablesFromState stub to return a variable + () => [{} as any] + ); + }); + + it('should enable submmenu', () => { + expect(model.isSubMenuVisible()).toBe(true); + }); + }); + + describe('isSubMenuVisible with hidden template var', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [{ hide: 2 }], + }, + }); + }); + + it('should not enable submmenu', () => { + expect(model.isSubMenuVisible()).toBe(false); + }); + }); + + describe('isSubMenuVisible with hidden annotation toggle', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + annotations: { + list: [{ hide: true }], + }, + }); + }); + + it('should not enable submmenu', () => { + expect(dashboard.isSubMenuVisible()).toBe(false); + }); + }); + + describe('When collapsing row', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + panels: [ + { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 2 } }, + { id: 2, type: 'row', gridPos: { x: 0, y: 2, w: 24, h: 2 } }, + { id: 3, type: 'graph', gridPos: { x: 0, y: 4, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 4, w: 12, h: 2 } }, + { id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 24, h: 2 } }, + ], + }); + dashboard.toggleRow(dashboard.panels[1]); + }); + + it('should remove panels and put them inside collapsed row', () => { + expect(dashboard.panels.length).toBe(3); + expect(dashboard.panels[1].panels.length).toBe(2); + }); + + describe('and when removing row and its panels', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], true); + }); + + it('should remove row and its panels', () => { + expect(dashboard.panels.length).toBe(2); + }); + }); + + describe('and when removing only the row', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], false); + }); + + it('should only remove row', () => { + expect(dashboard.panels.length).toBe(4); + }); + }); + }); + + describe('When expanding row', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + panels: [ + { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 6 } }, + { + id: 2, + type: 'row', + gridPos: { x: 0, y: 6, w: 24, h: 1 }, + collapsed: true, + panels: [ + { id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 7, w: 12, h: 2 } }, + ], + }, + { id: 5, type: 'row', gridPos: { x: 0, y: 7, w: 1, h: 1 } }, + ], + }); + dashboard.toggleRow(dashboard.panels[1]); + }); + + it('should add panels back', () => { + expect(dashboard.panels.length).toBe(5); + }); + + it('should add them below row in array', () => { + expect(dashboard.panels[2].id).toBe(3); + expect(dashboard.panels[3].id).toBe(4); + }); + + it('should position them below row', () => { + expect(dashboard.panels[2].gridPos).toMatchObject({ + x: 0, + y: 7, + w: 12, + h: 2, + }); + }); + + it('should move panels below down', () => { + expect(dashboard.panels[4].gridPos).toMatchObject({ + x: 0, + y: 9, + w: 1, + h: 1, + }); + }); + + describe('and when removing row and its panels', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], true); + }); + + it('should remove row and its panels', () => { + expect(dashboard.panels.length).toBe(2); + }); + }); + + describe('and when removing only the row', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], false); + }); + + it('should only remove row', () => { + expect(dashboard.panels.length).toBe(4); + }); + }); + }); + + describe('Given model with time', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + time: { + from: 'now-6h', + to: 'now', + }, + }); + expect(model.hasTimeChanged()).toBeFalsy(); + model.time = { + from: 'now-3h', + to: 'now-1h', + }; + }); + + it('hasTimeChanged should be true', () => { + expect(model.hasTimeChanged()).toBeTruthy(); + }); + + it('getSaveModelClone should return original time when saveTimerange=false', () => { + const options = { saveTimerange: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-6h'); + expect(saveModel.time.to).toBe('now'); + }); + + it('getSaveModelClone should return updated time when saveTimerange=true', () => { + const options = { saveTimerange: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-3h'); + expect(saveModel.time.to).toBe('now-1h'); + }); + + it('hasTimeChanged should be false when reset original time', () => { + model.resetOriginalTime(); + expect(model.hasTimeChanged()).toBeFalsy(); + }); + + it('getSaveModelClone should return original time when saveTimerange=false', () => { + const options = { saveTimerange: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-6h'); + expect(saveModel.time.to).toBe('now'); + }); + + it('getSaveModelClone should return updated time when saveTimerange=true', () => { + const options = { saveTimerange: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-3h'); + expect(saveModel.time.to).toBe('now-1h'); + }); + + it('getSaveModelClone should remove repeated panels and scopedVars', () => { + const dashboardJSON = { + panels: [ + { id: 1, type: 'row', repeat: 'dc', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'app', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } }, + ], + templating: { + list: [ + { + name: 'dc', + type: 'custom', + current: { + text: 'dc1 + dc2', + value: ['dc1', 'dc2'], + }, + options: [ + { text: 'dc1', value: 'dc1', selected: true }, + { text: 'dc2', value: 'dc2', selected: true }, + ], + }, + { + name: 'app', + type: 'custom', + current: { + text: 'se1 + se2', + value: ['se1', 'se2'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + ], + }, + ], + }, + }; + + const model = getDashboardModel(dashboardJSON); + model.processRepeats(); + expect(model.panels.filter((x) => x.type === 'row')).toHaveLength(2); + expect(model.panels.filter((x) => x.type !== 'row')).toHaveLength(4); + expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.dc.value).toBe('dc1'); + expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.app.value).toBe('se1'); + + const saveModel = model.getSaveModelClone(); + expect(saveModel.panels.length).toBe(2); + expect(saveModel.panels[0].scopedVars).toBe(undefined); + expect(saveModel.panels[1].scopedVars).toBe(undefined); + + model.collapseRows(); + const savedModelWithCollapsedRows: any = model.getSaveModelClone(); + expect(savedModelWithCollapsedRows.panels[0].panels.length).toBe(1); + }); + }); + + describe('Given model with template variable of type query', () => { + let model: DashboardModel; + + beforeEach(() => { + const json = { + templating: { + list: [ + { + name: 'Server', + type: 'query', + current: { + selected: true, + text: 'server_001', + value: 'server_001', + }, + }, + ], + }, + }; + model = getDashboardModel(json); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when adding a template variable', () => { + model.templating.list.push({ + name: 'Server2', + type: 'query', + current: { + selected: true, + text: 'server_002', + value: 'server_002', + }, + }); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when removing existing template variable', () => { + model.templating.list = []; + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be true when changing value of template variable', () => { + model.templating.list[0].current.text = 'server_002'; + expect(model.hasVariableValuesChanged()).toBeTruthy(); + }); + + it('getSaveModelClone should return original variable when saveVariables=false', () => { + model.templating.list[0].current.text = 'server_002'; + + const options = { saveVariables: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].current.text).toBe('server_001'); + }); + + it('getSaveModelClone should return updated variable when saveVariables=true', () => { + model.templating.list[0].current.text = 'server_002'; + + const options = { saveVariables: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].current.text).toBe('server_002'); + }); + }); + + describe('Given model with template variable of type adhoc', () => { + let model: DashboardModel; + + beforeEach(() => { + const json = { + templating: { + list: [ + { + name: 'Filter', + type: 'adhoc', + filters: [ + { + key: '@hostname', + operator: '=', + value: 'server 20', + }, + ], + }, + ], + }, + }; + model = getDashboardModel(json); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when adding a template variable', () => { + model.templating.list.push({ + name: 'Filter', + type: 'adhoc', + filters: [ + { + key: '@hostname', + operator: '=', + value: 'server 1', + }, + ], + }); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when removing existing template variable', () => { + model.templating.list = []; + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be true when changing value of filter', () => { + model.templating.list[0].filters[0].value = 'server 1'; + expect(model.hasVariableValuesChanged()).toBeTruthy(); + }); + + it('hasVariableValuesChanged should be true when adding an additional condition', () => { + model.templating.list[0].filters[0].condition = 'AND'; + model.templating.list[0].filters[1] = { + key: '@metric', + operator: '=', + value: 'logins.count', + }; + expect(model.hasVariableValuesChanged()).toBeTruthy(); + }); + + it('getSaveModelClone should return original variable when saveVariables=false', () => { + model.templating.list[0].filters[0].value = 'server 1'; + + const options = { saveVariables: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].filters[0].value).toBe('server 20'); + }); + + it('getSaveModelClone should return updated variable when saveVariables=true', () => { + model.templating.list[0].filters[0].value = 'server 1'; + + const options = { saveVariables: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].filters[0].value).toBe('server 1'); + }); + }); + + describe('Given a dashboard with one panel legend on and two off', () => { + let model: DashboardModel; + + beforeEach(() => { + const data = { + panels: [ + { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 2 }, legend: { show: true } }, + { id: 3, type: 'graph', gridPos: { x: 0, y: 4, w: 12, h: 2 }, legend: { show: false } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 4, w: 12, h: 2 }, legend: { show: false } }, + ], + }; + model = new DashboardModel(data); + }); + + it('toggleLegendsForAll should toggle all legends on on first execution', () => { + model.toggleLegendsForAll(); + const legendsOn = model.panels.filter((panel) => panel.legend!.show === true); + expect(legendsOn.length).toBe(3); + }); + + it('toggleLegendsForAll should toggle all legends off on second execution', () => { + model.toggleLegendsForAll(); + model.toggleLegendsForAll(); + const legendsOn = model.panels.filter((panel) => panel.legend!.show === true); + expect(legendsOn.length).toBe(0); + }); + }); + + describe('canAddAnnotations', () => { + it.each` + canEdit | canMakeEditable | expected + ${false} | ${false} | ${false} + ${false} | ${true} | ${true} + ${true} | ${false} | ${true} + ${true} | ${true} | ${true} + `( + 'when called with canEdit:{$canEdit}, canMakeEditable:{$canMakeEditable} and expected:{$expected}', + ({ canEdit, canMakeEditable, expected }) => { + const dashboard = new DashboardModel({}); + dashboard.meta.canEdit = canEdit; + dashboard.meta.canMakeEditable = canMakeEditable; + + const result = dashboard.canAddAnnotations(); + + expect(result).toBe(expected); + } + ); + }); +}); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts new file mode 100644 index 0000000..6a84c82 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -0,0 +1,1107 @@ +// Libaries +import { + cloneDeep, + defaults as _defaults, + each, + filter, + find, + findIndex, + indexOf, + isEqual, + map, + maxBy, + pull, + some, +} from 'lodash'; +// Constants +import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL } from 'app/core/constants'; +// Utils & Services +import { contextSrv } from 'app/core/services/context_srv'; +import sortByKeys from 'app/core/utils/sort_by_keys'; +// Types +import { GridPos, PanelModel } from './PanelModel'; +import { DashboardMigrator } from './DashboardMigrator'; +import { + AnnotationQuery, + AppEvent, + DashboardCursorSync, + dateTimeFormat, + dateTimeFormatTimeAgo, + DateTimeInput, + EventBusExtended, + EventBusSrv, + TimeRange, + TimeZone, + UrlQueryValue, +} from '@grafana/data'; +import { CoreEvents, DashboardMeta, KioskMode } from 'app/types'; +import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; +import { variableAdapters } from 'app/features/variables/adapters'; +import { onTimeRangeUpdated } from 'app/features/variables/state/actions'; +import { dispatch } from '../../../store/store'; +import { isAllVariable } from '../../variables/utils'; +import { DashboardPanelsChangedEvent, RefreshEvent, RenderEvent } from 'app/types/events'; + +export interface CloneOptions { + saveVariables?: boolean; + saveTimerange?: boolean; + message?: string; +} + +export type DashboardLinkType = 'link' | 'dashboards'; + +export interface DashboardLink { + icon: string; + title: string; + tooltip: string; + type: DashboardLinkType; + url: string; + asDropdown: boolean; + tags: any[]; + searchHits?: any[]; + targetBlank: boolean; + keepTime: boolean; + includeVars: boolean; +} + +export class DashboardModel { + id: any; + uid: string; + title: string; + autoUpdate: any; + description: any; + tags: any; + style: any; + timezone: any; + editable: any; + graphTooltip: DashboardCursorSync; + time: any; + private originalTime: any; + timepicker: any; + templating: { list: any[] }; + private originalTemplating: any; + annotations: { list: AnnotationQuery[] }; + refresh: any; + snapshot: any; + schemaVersion: number; + version: number; + revision: number; + links: DashboardLink[]; + gnetId: any; + panels: PanelModel[]; + panelInEdit?: PanelModel; + panelInView?: PanelModel; + + // ------------------ + // not persisted + // ------------------ + + // repeat process cycles + iteration?: number; + declare meta: DashboardMeta; + events: EventBusExtended; + + static nonPersistedProperties: { [str: string]: boolean } = { + events: true, + meta: true, + panels: true, // needs special handling + templating: true, // needs special handling + originalTime: true, + originalTemplating: true, + originalLibraryPanels: true, + panelInEdit: true, + panelInView: true, + getVariablesFromState: true, + formatDate: true, + }; + + constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) { + if (!data) { + data = {}; + } + + this.events = new EventBusSrv(); + this.id = data.id || null; + this.uid = data.uid || null; + this.revision = data.revision; + this.title = data.title || 'No Title'; + this.autoUpdate = data.autoUpdate; + this.description = data.description; + this.tags = data.tags || []; + this.style = data.style || 'dark'; + this.timezone = data.timezone || ''; + this.editable = data.editable !== false; + this.graphTooltip = data.graphTooltip || 0; + this.time = data.time || { from: 'now-6h', to: 'now' }; + this.timepicker = data.timepicker || {}; + this.templating = this.ensureListExist(data.templating); + this.annotations = this.ensureListExist(data.annotations); + this.refresh = data.refresh; + this.snapshot = data.snapshot; + this.schemaVersion = data.schemaVersion || 0; + this.version = data.version || 0; + this.links = data.links || []; + this.gnetId = data.gnetId || null; + this.panels = map(data.panels || [], (panelData: any) => new PanelModel(panelData)); + this.formatDate = this.formatDate.bind(this); + + this.resetOriginalVariables(true); + this.resetOriginalTime(); + + this.initMeta(meta); + this.updateSchema(data); + + this.addBuiltInAnnotationQuery(); + this.sortPanelsByGridPos(); + } + + addBuiltInAnnotationQuery() { + let found = false; + for (const item of this.annotations.list) { + if (item.builtIn === 1) { + found = true; + break; + } + } + + if (found) { + return; + } + + this.annotations.list.unshift({ + datasource: '-- Grafana --', + name: 'Annotations & Alerts', + type: 'dashboard', + iconColor: DEFAULT_ANNOTATION_COLOR, + enable: true, + hide: true, + builtIn: 1, + }); + } + + private initMeta(meta?: DashboardMeta) { + meta = meta || {}; + + meta.canShare = meta.canShare !== false; + meta.canSave = meta.canSave !== false; + meta.canStar = meta.canStar !== false; + meta.canEdit = meta.canEdit !== false; + meta.showSettings = meta.canEdit; + meta.canMakeEditable = meta.canSave && !this.editable; + meta.hasUnsavedFolderChange = false; + + if (!this.editable) { + meta.canEdit = false; + meta.canDelete = false; + meta.canSave = false; + } + + this.meta = meta; + } + + // cleans meta data and other non persistent state + getSaveModelClone(options?: CloneOptions): DashboardModel { + const defaults = _defaults(options || {}, { + saveVariables: true, + saveTimerange: true, + }); + + // make clone + let copy: any = {}; + for (const property in this) { + if (DashboardModel.nonPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + copy[property] = cloneDeep(this[property]); + } + + this.updateTemplatingSaveModelClone(copy, defaults); + + if (!defaults.saveTimerange) { + copy.time = this.originalTime; + } + + // get panel save models + copy.panels = this.getPanelSaveModels(); + + // sort by keys + copy = sortByKeys(copy); + copy.getVariables = () => { + return copy.templating.list; + }; + + return copy; + } + + private getPanelSaveModels() { + return this.panels + .filter((panel: PanelModel) => { + if (panel.type === 'add-panel') { + return false; + } + // skip repeated panels in the saved model + if (panel.repeatPanelId) { + return false; + } + // skip repeated rows in the saved model + if (panel.repeatedByRow) { + return false; + } + return true; + }) + .map((panel: PanelModel) => { + // If we save while editing we should include the panel in edit mode instead of the + // unmodified source panel + if (this.panelInEdit && this.panelInEdit.editSourceId === panel.id) { + const saveModel = this.panelInEdit.getSaveModel(); + // while editing a panel we modify its id, need to restore it here + saveModel.id = this.panelInEdit.editSourceId; + return saveModel; + } + + return panel.getSaveModel(); + }) + .map((model: any) => { + // Clear any scopedVars from persisted mode. This cannot be part of getSaveModel as we need to be able to copy + // panel models with preserved scopedVars, for example when going into edit mode. + delete model.scopedVars; + + // Clear any repeated panels from collapsed rows + if (model.type === 'row' && model.panels && model.panels.length > 0) { + model.panels = model.panels + .filter((rowPanel: PanelModel) => !rowPanel.repeatPanelId) + .map((model: PanelModel) => { + delete model.scopedVars; + return model; + }); + } + + return model; + }); + } + + private updateTemplatingSaveModelClone( + copy: any, + defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions + ) { + const originalVariables = this.originalTemplating; + const currentVariables = this.getVariablesFromState(); + + copy.templating = { + list: currentVariables.map((variable) => + variableAdapters.get(variable.type).getSaveModel(variable, defaults.saveVariables) + ), + }; + + if (!defaults.saveVariables) { + for (let i = 0; i < copy.templating.list.length; i++) { + const current = copy.templating.list[i]; + const original: any = find(originalVariables, { name: current.name, type: current.type }); + + if (!original) { + continue; + } + + if (current.type === 'adhoc') { + copy.templating.list[i].filters = original.filters; + } else { + copy.templating.list[i].current = original.current; + } + } + } + } + + timeRangeUpdated(timeRange: TimeRange) { + this.events.emit(CoreEvents.timeRangeUpdated, timeRange); + dispatch(onTimeRangeUpdated(timeRange)); + } + + startRefresh() { + this.events.publish(new RefreshEvent()); + + if (this.panelInEdit) { + this.panelInEdit.refresh(); + return; + } + + for (const panel of this.panels) { + if (!this.otherPanelInFullscreen(panel)) { + panel.refresh(); + } + } + } + + render() { + this.events.publish(new RenderEvent()); + for (const panel of this.panels) { + panel.render(); + } + } + + panelInitialized(panel: PanelModel) { + const lastResult = panel.getQueryRunner().getLastResult(); + + if (!this.otherPanelInFullscreen(panel) && !lastResult) { + panel.refresh(); + } + } + + otherPanelInFullscreen(panel: PanelModel) { + return (this.panelInEdit || this.panelInView) && !(panel.isViewing || panel.isEditing); + } + + initEditPanel(sourcePanel: PanelModel): PanelModel { + this.panelInEdit = sourcePanel.getEditClone(); + return this.panelInEdit; + } + + initViewPanel(panel: PanelModel) { + this.panelInView = panel; + panel.setIsViewing(true); + } + + exitViewPanel(panel: PanelModel) { + this.panelInView = undefined; + panel.setIsViewing(false); + } + + exitPanelEditor() { + this.panelInEdit!.destroy(); + this.panelInEdit = undefined; + } + + private ensureListExist(data: any) { + if (!data) { + data = {}; + } + if (!data.list) { + data.list = []; + } + return data; + } + + getNextPanelId() { + let max = 0; + + for (const panel of this.panels) { + if (panel.id > max) { + max = panel.id; + } + + if (panel.collapsed) { + for (const rowPanel of panel.panels) { + if (rowPanel.id > max) { + max = rowPanel.id; + } + } + } + } + + return max + 1; + } + + forEachPanel(callback: (panel: PanelModel, index: number) => void) { + for (let i = 0; i < this.panels.length; i++) { + callback(this.panels[i], i); + } + } + + getPanelById(id: number): PanelModel | null { + if (this.panelInEdit && this.panelInEdit.id === id) { + return this.panelInEdit; + } + + for (const panel of this.panels) { + if (panel.id === id) { + return panel; + } + } + + return null; + } + + canEditPanel(panel?: PanelModel | null): boolean | undefined | null { + return this.meta.canEdit && panel && !panel.repeatPanelId; + } + + canEditPanelById(id: number): boolean | undefined | null { + return this.canEditPanel(this.getPanelById(id)); + } + + addPanel(panelData: any) { + panelData.id = this.getNextPanelId(); + + this.panels.unshift(new PanelModel(panelData)); + + this.sortPanelsByGridPos(); + + this.events.publish(new DashboardPanelsChangedEvent()); + } + + sortPanelsByGridPos() { + this.panels.sort((panelA, panelB) => { + if (panelA.gridPos.y === panelB.gridPos.y) { + return panelA.gridPos.x - panelB.gridPos.x; + } else { + return panelA.gridPos.y - panelB.gridPos.y; + } + }); + } + + cleanUpRepeats() { + if (this.isSnapshotTruthy() || !this.hasVariables()) { + return; + } + + this.iteration = (this.iteration || new Date().getTime()) + 1; + const panelsToRemove = []; + + // cleanup scopedVars + for (const panel of this.panels) { + delete panel.scopedVars; + } + + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + if ((!panel.repeat || panel.repeatedByRow) && panel.repeatPanelId && panel.repeatIteration !== this.iteration) { + panelsToRemove.push(panel); + } + } + + // remove panels + pull(this.panels, ...panelsToRemove); + panelsToRemove.map((p) => p.destroy()); + this.sortPanelsByGridPos(); + this.events.publish(new DashboardPanelsChangedEvent()); + } + + processRepeats() { + if (this.isSnapshotTruthy() || !this.hasVariables()) { + return; + } + + this.cleanUpRepeats(); + + this.iteration = (this.iteration || new Date().getTime()) + 1; + + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + if (panel.repeat) { + this.repeatPanel(panel, i); + } + } + + this.sortPanelsByGridPos(); + this.events.publish(new DashboardPanelsChangedEvent()); + } + + cleanUpRowRepeats(rowPanels: PanelModel[]) { + const panelsToRemove = []; + for (let i = 0; i < rowPanels.length; i++) { + const panel = rowPanels[i]; + if (!panel.repeat && panel.repeatPanelId) { + panelsToRemove.push(panel); + } + } + pull(rowPanels, ...panelsToRemove); + pull(this.panels, ...panelsToRemove); + } + + processRowRepeats(row: PanelModel) { + if (this.isSnapshotTruthy() || !this.hasVariables()) { + return; + } + + let rowPanels = row.panels; + if (!row.collapsed) { + const rowPanelIndex = findIndex(this.panels, (p: PanelModel) => p.id === row.id); + rowPanels = this.getRowPanels(rowPanelIndex); + } + + this.cleanUpRowRepeats(rowPanels); + + for (let i = 0; i < rowPanels.length; i++) { + const panel = rowPanels[i]; + if (panel.repeat) { + const panelIndex = findIndex(this.panels, (p: PanelModel) => p.id === panel.id); + this.repeatPanel(panel, panelIndex); + } + } + } + + getPanelRepeatClone(sourcePanel: PanelModel, valueIndex: number, sourcePanelIndex: number) { + // if first clone return source + if (valueIndex === 0) { + return sourcePanel; + } + + const clone = new PanelModel(sourcePanel.getSaveModel()); + + clone.id = this.getNextPanelId(); + + // insert after source panel + value index + this.panels.splice(sourcePanelIndex + valueIndex, 0, clone); + + clone.repeatIteration = this.iteration; + clone.repeatPanelId = sourcePanel.id; + clone.repeat = undefined; + + if (this.panelInView?.id === clone.id) { + clone.setIsViewing(true); + this.panelInView = clone; + } + + return clone; + } + + getRowRepeatClone(sourceRowPanel: PanelModel, valueIndex: number, sourcePanelIndex: number) { + // if first clone return source + if (valueIndex === 0) { + if (!sourceRowPanel.collapsed) { + const rowPanels = this.getRowPanels(sourcePanelIndex); + sourceRowPanel.panels = rowPanels; + } + return sourceRowPanel; + } + + const clone = new PanelModel(sourceRowPanel.getSaveModel()); + // for row clones we need to figure out panels under row to clone and where to insert clone + let rowPanels: PanelModel[], insertPos: number; + if (sourceRowPanel.collapsed) { + rowPanels = cloneDeep(sourceRowPanel.panels); + clone.panels = rowPanels; + // insert copied row after preceding row + insertPos = sourcePanelIndex + valueIndex; + } else { + rowPanels = this.getRowPanels(sourcePanelIndex); + clone.panels = map(rowPanels, (panel: PanelModel) => panel.getSaveModel()); + // insert copied row after preceding row's panels + insertPos = sourcePanelIndex + (rowPanels.length + 1) * valueIndex; + } + this.panels.splice(insertPos, 0, clone); + + this.updateRepeatedPanelIds(clone); + return clone; + } + + repeatPanel(panel: PanelModel, panelIndex: number) { + const variable: any = this.getPanelRepeatVariable(panel); + if (!variable) { + return; + } + + if (panel.type === 'row') { + this.repeatRow(panel, panelIndex, variable); + return; + } + + const selectedOptions = this.getSelectedVariableOptions(variable); + + const maxPerRow = panel.maxPerRow || 4; + let xPos = 0; + let yPos = panel.gridPos.y; + + for (let index = 0; index < selectedOptions.length; index++) { + const option = selectedOptions[index]; + let copy; + + copy = this.getPanelRepeatClone(panel, index, panelIndex); + copy.scopedVars = copy.scopedVars || {}; + copy.scopedVars[variable.name] = option; + + if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { + if (index > 0) { + yPos += copy.gridPos.h; + } + copy.gridPos.y = yPos; + } else { + // set width based on how many are selected + // assumed the repeated panels should take up full row width + copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, GRID_COLUMN_COUNT / maxPerRow); + copy.gridPos.x = xPos; + copy.gridPos.y = yPos; + + xPos += copy.gridPos.w; + + // handle overflow by pushing down one row + if (xPos + copy.gridPos.w > GRID_COLUMN_COUNT) { + xPos = 0; + yPos += copy.gridPos.h; + } + } + } + + // Update gridPos for panels below + const yOffset = yPos - panel.gridPos.y; + if (yOffset > 0) { + const panelBelowIndex = panelIndex + selectedOptions.length; + for (let i = panelBelowIndex; i < this.panels.length; i++) { + this.panels[i].gridPos.y += yOffset; + } + } + } + + repeatRow(panel: PanelModel, panelIndex: number, variable: any) { + const selectedOptions = this.getSelectedVariableOptions(variable); + let yPos = panel.gridPos.y; + + function setScopedVars(panel: PanelModel, variableOption: any) { + panel.scopedVars = panel.scopedVars || {}; + panel.scopedVars[variable.name] = variableOption; + } + + for (let optionIndex = 0; optionIndex < selectedOptions.length; optionIndex++) { + const option = selectedOptions[optionIndex]; + const rowCopy = this.getRowRepeatClone(panel, optionIndex, panelIndex); + setScopedVars(rowCopy, option); + + const rowHeight = this.getRowHeight(rowCopy); + const rowPanels = rowCopy.panels || []; + let panelBelowIndex; + + if (panel.collapsed) { + // For collapsed row just copy its panels and set scoped vars and proper IDs + each(rowPanels, (rowPanel: PanelModel, i: number) => { + setScopedVars(rowPanel, option); + if (optionIndex > 0) { + this.updateRepeatedPanelIds(rowPanel, true); + } + }); + rowCopy.gridPos.y += optionIndex; + yPos += optionIndex; + panelBelowIndex = panelIndex + optionIndex + 1; + } else { + // insert after 'row' panel + const insertPos = panelIndex + (rowPanels.length + 1) * optionIndex + 1; + each(rowPanels, (rowPanel: PanelModel, i: number) => { + setScopedVars(rowPanel, option); + if (optionIndex > 0) { + const cloneRowPanel = new PanelModel(rowPanel); + this.updateRepeatedPanelIds(cloneRowPanel, true); + // For exposed row additionally set proper Y grid position and add it to dashboard panels + cloneRowPanel.gridPos.y += rowHeight * optionIndex; + this.panels.splice(insertPos + i, 0, cloneRowPanel); + } + }); + rowCopy.panels = []; + rowCopy.gridPos.y += rowHeight * optionIndex; + yPos += rowHeight; + panelBelowIndex = insertPos + rowPanels.length; + } + + // Update gridPos for panels below + for (let i = panelBelowIndex; i < this.panels.length; i++) { + this.panels[i].gridPos.y += yPos; + } + } + } + + updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) { + panel.repeatPanelId = panel.id; + panel.id = this.getNextPanelId(); + panel.repeatIteration = this.iteration; + if (repeatedByRow) { + panel.repeatedByRow = true; + } else { + panel.repeat = undefined; + } + return panel; + } + + getSelectedVariableOptions(variable: any) { + let selectedOptions: any[]; + if (isAllVariable(variable)) { + selectedOptions = variable.options.slice(1, variable.options.length); + } else { + selectedOptions = filter(variable.options, { selected: true }); + } + return selectedOptions; + } + + getRowHeight(rowPanel: PanelModel): number { + if (!rowPanel.panels || rowPanel.panels.length === 0) { + return 0; + } + const rowYPos = rowPanel.gridPos.y; + const positions = map(rowPanel.panels, 'gridPos'); + const maxPos = maxBy(positions, (pos: GridPos) => { + return pos.y + pos.h; + }); + return maxPos.y + maxPos.h - rowYPos; + } + + removePanel(panel: PanelModel) { + this.panels = this.panels.filter((item) => item !== panel); + this.events.publish(new DashboardPanelsChangedEvent()); + } + + removeRow(row: PanelModel, removePanels: boolean) { + const needToogle = (!removePanels && row.collapsed) || (removePanels && !row.collapsed); + + if (needToogle) { + this.toggleRow(row); + } + + this.removePanel(row); + } + + expandRows() { + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + + if (panel.type !== 'row') { + continue; + } + + if (panel.collapsed) { + this.toggleRow(panel); + } + } + } + + collapseRows() { + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + + if (panel.type !== 'row') { + continue; + } + + if (!panel.collapsed) { + this.toggleRow(panel); + } + } + } + + isSubMenuVisible() { + if (this.links.length > 0) { + return true; + } + + if (this.getVariables().find((variable) => variable.hide !== 2)) { + return true; + } + + if (this.annotations.list.find((annotation) => annotation.hide !== true)) { + return true; + } + + return false; + } + + getPanelInfoById(panelId: number) { + for (let i = 0; i < this.panels.length; i++) { + if (this.panels[i].id === panelId) { + return { + panel: this.panels[i], + index: i, + }; + } + } + + return null; + } + + duplicatePanel(panel: PanelModel) { + const newPanel = panel.getSaveModel(); + newPanel.id = this.getNextPanelId(); + + delete newPanel.repeat; + delete newPanel.repeatIteration; + delete newPanel.repeatPanelId; + delete newPanel.scopedVars; + if (newPanel.alert) { + delete newPanel.thresholds; + } + delete newPanel.alert; + + // does it fit to the right? + if (panel.gridPos.x + panel.gridPos.w * 2 <= GRID_COLUMN_COUNT) { + newPanel.gridPos.x += panel.gridPos.w; + } else { + // add below + newPanel.gridPos.y += panel.gridPos.h; + } + + this.addPanel(newPanel); + return newPanel; + } + + formatDate(date: DateTimeInput, format?: string) { + return dateTimeFormat(date, { + format, + timeZone: this.getTimezone(), + }); + } + + destroy() { + this.events.removeAllListeners(); + for (const panel of this.panels) { + panel.destroy(); + } + } + + toggleRow(row: PanelModel) { + const rowIndex = indexOf(this.panels, row); + + if (row.collapsed) { + row.collapsed = false; + const hasRepeat = some(row.panels as PanelModel[], (p: PanelModel) => p.repeat); + + if (row.panels.length > 0) { + // Use first panel to figure out if it was moved or pushed + const firstPanel = row.panels[0]; + const yDiff = firstPanel.gridPos.y - (row.gridPos.y + row.gridPos.h); + + // start inserting after row + let insertPos = rowIndex + 1; + // y max will represent the bottom y pos after all panels have been added + // needed to know home much panels below should be pushed down + let yMax = row.gridPos.y; + + for (const panel of row.panels) { + // make sure y is adjusted (in case row moved while collapsed) + // console.log('yDiff', yDiff); + panel.gridPos.y -= yDiff; + // insert after row + this.panels.splice(insertPos, 0, new PanelModel(panel)); + // update insert post and y max + insertPos += 1; + yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h); + } + + const pushDownAmount = yMax - row.gridPos.y - 1; + + // push panels below down + for (let panelIndex = insertPos; panelIndex < this.panels.length; panelIndex++) { + this.panels[panelIndex].gridPos.y += pushDownAmount; + } + + row.panels = []; + + if (hasRepeat) { + this.processRowRepeats(row); + } + } + + // sort panels + this.sortPanelsByGridPos(); + + // emit change event + this.events.publish(new DashboardPanelsChangedEvent()); + return; + } + + const rowPanels = this.getRowPanels(rowIndex); + + // remove panels + pull(this.panels, ...rowPanels); + // save panel models inside row panel + row.panels = map(rowPanels, (panel: PanelModel) => panel.getSaveModel()); + row.collapsed = true; + + // emit change event + this.events.publish(new DashboardPanelsChangedEvent()); + } + + /** + * Will return all panels after rowIndex until it encounters another row + */ + getRowPanels(rowIndex: number): PanelModel[] { + const rowPanels = []; + + for (let index = rowIndex + 1; index < this.panels.length; index++) { + const panel = this.panels[index]; + + // break when encountering another row + if (panel.type === 'row') { + break; + } + + // this panel must belong to row + rowPanels.push(panel); + } + + return rowPanels; + } + + /** @deprecated */ + on(event: AppEvent, callback: (payload?: T) => void) { + console.log('DashboardModel.on is deprecated use events.subscribe'); + this.events.on(event, callback); + } + + /** @deprecated */ + off(event: AppEvent, callback: (payload?: T) => void) { + console.log('DashboardModel.off is deprecated'); + this.events.off(event, callback); + } + + cycleGraphTooltip() { + this.graphTooltip = (this.graphTooltip + 1) % 3; + } + + sharedTooltipModeEnabled() { + return this.graphTooltip > 0; + } + + sharedCrosshairModeOnly() { + return this.graphTooltip === 1; + } + + getRelativeTime(date: DateTimeInput) { + return dateTimeFormatTimeAgo(date, { + timeZone: this.getTimezone(), + }); + } + + isSnapshot() { + return this.snapshot !== undefined; + } + + getTimezone(): TimeZone { + return (this.timezone ? this.timezone : contextSrv?.user?.timezone) as TimeZone; + } + + private updateSchema(old: any) { + const migrator = new DashboardMigrator(this); + migrator.updateSchema(old); + } + + resetOriginalTime() { + this.originalTime = cloneDeep(this.time); + } + + hasTimeChanged() { + return !isEqual(this.time, this.originalTime); + } + + resetOriginalVariables(initial = false) { + if (initial) { + this.originalTemplating = this.cloneVariablesFrom(this.templating.list); + return; + } + + this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState()); + } + + hasVariableValuesChanged() { + return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState()); + } + + autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { + const currentGridHeight = Math.max( + ...this.panels.map((panel) => { + return panel.gridPos.h + panel.gridPos.y; + }) + ); + + const navbarHeight = 55; + const margin = 20; + const submenuHeight = 50; + + let visibleHeight = viewHeight - navbarHeight - margin; + + // Remove submenu height if visible + if (this.meta.submenuEnabled && !kioskMode) { + visibleHeight -= submenuHeight; + } + + // add back navbar height + if (kioskMode && kioskMode !== KioskMode.TV) { + visibleHeight += navbarHeight; + } + + const visibleGridHeight = Math.floor(visibleHeight / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); + const scaleFactor = currentGridHeight / visibleGridHeight; + + this.panels.forEach((panel, i) => { + panel.gridPos.y = Math.round(panel.gridPos.y / scaleFactor) || 1; + panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1; + }); + } + + templateVariableValueUpdated() { + this.processRepeats(); + this.events.emit(CoreEvents.templateVariableValueUpdated); + } + + expandParentRowFor(panelId: number) { + for (const panel of this.panels) { + if (panel.collapsed) { + for (const rowPanel of panel.panels) { + if (rowPanel.id === panelId) { + this.toggleRow(panel); + return; + } + } + } + } + } + + toggleLegendsForAll() { + const panelsWithLegends = this.panels.filter((panel) => { + return panel.legend !== undefined && panel.legend !== null; + }); + + // determine if more panels are displaying legends or not + const onCount = panelsWithLegends.filter((panel) => panel.legend!.show).length; + const offCount = panelsWithLegends.length - onCount; + const panelLegendsOn = onCount >= offCount; + + for (const panel of panelsWithLegends) { + panel.legend!.show = !panelLegendsOn; + panel.render(); + } + } + + getVariables = () => { + return this.getVariablesFromState(); + }; + + canAddAnnotations() { + return this.meta.canEdit || this.meta.canMakeEditable; + } + + private getPanelRepeatVariable(panel: PanelModel) { + return this.getVariablesFromState().find((variable) => variable.name === panel.repeat); + } + + private isSnapshotTruthy() { + return this.snapshot; + } + + private hasVariables() { + return this.getVariablesFromState().length > 0; + } + + private hasVariablesChanged(originalVariables: any[], currentVariables: any[]): boolean { + if (originalVariables.length !== currentVariables.length) { + return false; + } + + const updated = map(currentVariables, (variable: any) => { + return { + name: variable.name, + type: variable.type, + current: cloneDeep(variable.current), + filters: cloneDeep(variable.filters), + }; + }); + + return !isEqual(updated, originalVariables); + } + + private cloneVariablesFrom(variables: any[]): any[] { + return variables.map((variable) => { + return { + name: variable.name, + type: variable.type, + current: cloneDeep(variable.current), + filters: cloneDeep(variable.filters), + }; + }); + } +} diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts new file mode 100644 index 0000000..6315ac6 --- /dev/null +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -0,0 +1,468 @@ +import { PanelModel } from './PanelModel'; +import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { + DataLinkBuiltInVars, + FieldConfigProperty, + PanelData, + PanelProps, + standardEditorsRegistry, + standardFieldConfigEditorRegistry, +} from '@grafana/data'; +import { ComponentClass } from 'react'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import { setTimeSrv } from '../services/TimeSrv'; +import { TemplateSrv } from '../../templating/template_srv'; +import { setTemplateSrv } from '@grafana/runtime'; +import { variableAdapters } from '../../variables/adapters'; +import { createQueryVariableAdapter } from '../../variables/query/adapter'; +import { mockStandardFieldConfigOptions } from '../../../../test/helpers/fieldConfig'; +import { queryBuilder } from 'app/features/variables/shared/testing/builders'; + +standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); +standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); + +setTimeSrv({ + timeRangeForUrl: () => ({ + from: 1607687293000, + to: 1607687293100, + }), +} as any); + +const getVariables = () => variablesMock; +const getVariableWithName = (name: string) => variablesMock.filter((v) => v.name === name)[0]; +const getFilteredVariables = jest.fn(); + +setTemplateSrv( + new TemplateSrv({ + getVariables, + getVariableWithName, + getFilteredVariables, + }) +); + +variableAdapters.setInit(() => [createQueryVariableAdapter()]); + +describe('PanelModel', () => { + describe('when creating new panel model', () => { + let model: any; + let modelJson: any; + let persistedOptionsMock; + + const tablePlugin = getPanelPlugin( + { + id: 'table', + }, + (null as unknown) as ComponentClass, // react + {} // angular + ); + + tablePlugin.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + description: '', + }); + }); + + tablePlugin.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Unit]: { + defaultValue: 'flop', + }, + [FieldConfigProperty.Decimals]: { + defaultValue: 2, + }, + }, + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'CustomProp', + path: 'customProp', + defaultValue: false, + }); + }, + }); + + beforeEach(() => { + persistedOptionsMock = { + fieldOptions: { + thresholds: [ + { + color: '#F2495C', + index: 1, + value: 50, + }, + { + color: '#73BF69', + index: 0, + value: null, + }, + ], + }, + arrayWith2Values: [{ name: 'changed to only one value' }], + }; + + modelJson = { + type: 'table', + maxDataPoints: 100, + interval: '5m', + showColumns: true, + targets: [{ refId: 'A' }, { noRefId: true }], + options: persistedOptionsMock, + fieldConfig: { + defaults: { + unit: 'mpg', + thresholds: { + mode: 'absolute', + steps: [ + { color: 'green', value: null }, + { color: 'red', value: 80 }, + ], + }, + }, + overrides: [ + { + matcher: { + id: '1', + options: {}, + }, + properties: [ + { + id: 'thresholds', + value: { + mode: 'absolute', + steps: [ + { color: 'green', value: null }, + { color: 'red', value: 80 }, + ], + }, + }, + ], + }, + ], + }, + }; + + model = new PanelModel(modelJson); + model.pluginLoaded(tablePlugin); + }); + + it('should apply defaults', () => { + expect(model.gridPos.h).toBe(3); + }); + + it('should apply option defaults', () => { + expect(model.getOptions().showThresholds).toBeTruthy(); + }); + + it('should change null thresholds to negative infinity', () => { + expect(model.fieldConfig.defaults.thresholds.steps[0].value).toBe(-Infinity); + expect(model.fieldConfig.overrides[0].properties[0].value.steps[0].value).toBe(-Infinity); + }); + + it('should apply option defaults but not override if array is changed', () => { + expect(model.getOptions().arrayWith2Values.length).toBe(1); + }); + + it('should apply field config defaults', () => { + // default unit is overriden by model + expect(model.getFieldOverrideOptions().fieldConfig.defaults.unit).toBe('mpg'); + // default decimals are aplied + expect(model.getFieldOverrideOptions().fieldConfig.defaults.decimals).toBe(2); + }); + + it('should set model props on instance', () => { + expect(model.showColumns).toBe(true); + }); + + it('should add missing refIds', () => { + expect(model.targets[1].refId).toBe('B'); + }); + + it("shouldn't break panel with non-array targets", () => { + modelJson.targets = { + 0: { refId: 'A' }, + foo: { bar: 'baz' }, + }; + model = new PanelModel(modelJson); + expect(model.targets[0].refId).toBe('A'); + }); + + it('getSaveModel should remove defaults', () => { + const saveModel = model.getSaveModel(); + expect(saveModel.gridPos).toBe(undefined); + }); + + it('getSaveModel should not remove datasource default', () => { + const saveModel = model.getSaveModel(); + expect(saveModel.datasource).toBe(null); + }); + + it('getSaveModel should remove nonPersistedProperties', () => { + const saveModel = model.getSaveModel(); + expect(saveModel.events).toBe(undefined); + }); + + describe('variables interpolation', () => { + beforeEach(() => { + model.scopedVars = { + aaa: { value: 'AAA', text: 'upperA' }, + bbb: { value: 'BBB', text: 'upperB' }, + }; + }); + it('should interpolate variables', () => { + const out = model.replaceVariables('hello $aaa'); + expect(out).toBe('hello AAA'); + }); + + it('should interpolate $__url_time_range variable', () => { + const out = model.replaceVariables(`/d/1?$${DataLinkBuiltInVars.keepTime}`); + expect(out).toBe('/d/1?from=1607687293000&to=1607687293100'); + }); + + it('should interpolate $__all_variables variable', () => { + const out = model.replaceVariables(`/d/1?$${DataLinkBuiltInVars.includeVars}`); + expect(out).toBe('/d/1?var-test1=val1&var-test2=val2&var-test3=Value%203&var-test4=A&var-test4=B'); + }); + + it('should prefer the local variable value', () => { + const extra = { aaa: { text: '???', value: 'XXX' } }; + const out = model.replaceVariables('hello $aaa and $bbb', extra); + expect(out).toBe('hello XXX and BBB'); + }); + }); + + describe('when changing panel type', () => { + beforeEach(() => { + const newPlugin = getPanelPlugin({ id: 'graph' }); + + newPlugin.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byThresholdsSupport: true, + }, + }, + }, + useCustomConfig: (builder) => { + builder.addNumberInput({ + path: 'customProp', + name: 'customProp', + defaultValue: 100, + }); + }, + }); + + newPlugin.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds labels', + path: 'showThresholdLabels', + defaultValue: false, + description: '', + }); + }); + + model.editSourceId = 1001; + model.fieldConfig.defaults.decimals = 3; + model.fieldConfig.defaults.custom = { + customProp: true, + }; + model.fieldConfig.overrides = [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customProp', + value: false, + }, + { + id: 'decimals', + value: 0, + }, + ], + }, + ]; + model.changePlugin(newPlugin); + model.alert = { id: 2 }; + }); + + it('should keep editSourceId', () => { + expect(model.editSourceId).toBe(1001); + }); + + it('should keep maxDataPoints', () => { + expect(model.maxDataPoints).toBe(100); + }); + + it('should keep interval', () => { + expect(model.interval).toBe('5m'); + }); + + it('should preseve standard field config', () => { + expect(model.fieldConfig.defaults.decimals).toEqual(3); + }); + + it('should clear custom field config and apply new defaults', () => { + expect(model.fieldConfig.defaults.custom).toEqual({ + customProp: 100, + }); + }); + + it('should remove overrides with custom props', () => { + expect(model.fieldConfig.overrides.length).toEqual(1); + expect(model.fieldConfig.overrides[0].properties[0].id).toEqual('decimals'); + }); + + it('should apply next panel option defaults', () => { + expect(model.getOptions().showThresholdLabels).toBeFalsy(); + expect(model.getOptions().showThresholds).toBeUndefined(); + }); + + it('should remove table properties but keep core props', () => { + expect(model.showColumns).toBe(undefined); + }); + + it('should restore table properties when changing back', () => { + model.changePlugin(tablePlugin); + expect(model.showColumns).toBe(true); + }); + + it('should restore custom field config to what it was and preserve standard options', () => { + model.changePlugin(tablePlugin); + expect(model.fieldConfig.defaults.custom.customProp).toBe(true); + }); + + it('should remove alert rule when changing type that does not support it', () => { + model.changePlugin(getPanelPlugin({ id: 'table' })); + expect(model.alert).toBe(undefined); + }); + }); + + describe('when changing to react panel from angular panel', () => { + let panelQueryRunner: any; + + const onPanelTypeChanged = jest.fn(); + const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any); + + beforeEach(() => { + model.changePlugin(reactPlugin); + panelQueryRunner = model.getQueryRunner(); + }); + + it('should call react onPanelTypeChanged', () => { + expect(onPanelTypeChanged.mock.calls.length).toBe(1); + expect(onPanelTypeChanged.mock.calls[0][1]).toBe('table'); + expect(onPanelTypeChanged.mock.calls[0][2].angular).toBeDefined(); + }); + + it('getQueryRunner() should return same instance after changing to another react panel', () => { + model.changePlugin(getPanelPlugin({ id: 'react2' })); + const sameQueryRunner = model.getQueryRunner(); + expect(panelQueryRunner).toBe(sameQueryRunner); + }); + }); + + describe('variables interpolation', () => { + let panelQueryRunner: any; + + const onPanelTypeChanged = jest.fn(); + const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any); + + beforeEach(() => { + model.changePlugin(reactPlugin); + panelQueryRunner = model.getQueryRunner(); + }); + + it('should call react onPanelTypeChanged', () => { + expect(onPanelTypeChanged.mock.calls.length).toBe(1); + expect(onPanelTypeChanged.mock.calls[0][1]).toBe('table'); + expect(onPanelTypeChanged.mock.calls[0][2].angular).toBeDefined(); + }); + + it('getQueryRunner() should return same instance after changing to another react panel', () => { + model.changePlugin(getPanelPlugin({ id: 'react2' })); + const sameQueryRunner = model.getQueryRunner(); + expect(panelQueryRunner).toBe(sameQueryRunner); + }); + }); + + describe('restoreModel', () => { + it('Should clean state and set properties from model', () => { + model.restoreModel({ + title: 'New title', + options: { new: true }, + }); + expect(model.title).toBe('New title'); + expect(model.options.new).toBe(true); + }); + + it('Should delete properties that are now gone on new model', () => { + model.someProperty = 'value'; + model.restoreModel({ + title: 'New title', + options: {}, + }); + + expect(model.someProperty).toBeUndefined(); + }); + + it('Should remove old angular panel specific props', () => { + model.axes = [{ prop: 1 }]; + model.thresholds = []; + + model.restoreModel({ + title: 'New title', + options: {}, + }); + + expect(model.axes).toBeUndefined(); + expect(model.thresholds).toBeUndefined(); + }); + + it('Should be able to set defaults back to default', () => { + model.transparent = true; + + model.restoreModel({}); + expect(model.transparent).toBe(false); + }); + }); + + describe('destroy', () => { + it('Should still preserve last query result', () => { + model.getQueryRunner().useLastResultFrom({ + getLastResult: () => ({} as PanelData), + } as PanelQueryRunner); + + model.destroy(); + expect(model.getQueryRunner().getLastResult()).toBeDefined(); + }); + }); + + describe('getDisplayTitle', () => { + it('when called then it should interpolate singe value variables in title', () => { + const model = new PanelModel({ + title: 'Single value variable [[test3]] ${test3} ${test3:percentencode}', + }); + const title = model.getDisplayTitle(); + + expect(title).toEqual('Single value variable Value 3 Value 3 Value%203'); + }); + + it('when called then it should interpolate multi value variables in title', () => { + const model = new PanelModel({ + title: 'Multi value variable [[test4]] ${test4} ${test4:percentencode}', + }); + const title = model.getDisplayTitle(); + + expect(title).toEqual('Multi value variable A + B A + B %7BA%2CB%7D'); + }); + }); + }); +}); + +const variablesMock = [ + queryBuilder().withId('test1').withName('test1').withCurrent('val1').build(), + queryBuilder().withId('test2').withName('test2').withCurrent('val2').build(), + queryBuilder().withId('test3').withName('test3').withCurrent('Value 3').build(), + queryBuilder().withId('test4').withName('test4').withCurrent(['A', 'B']).build(), +]; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts new file mode 100644 index 0000000..ebdd84a --- /dev/null +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -0,0 +1,593 @@ +// Libraries +import { cloneDeep, defaultsDeep, isArray, isEqual, keys } from 'lodash'; +// Utils +import { getTemplateSrv } from '@grafana/runtime'; +import { getNextRefIdChar } from 'app/core/utils/query'; +// Types +import { + DataConfigSource, + DataFrameDTO, + DataLink, + DataLinkBuiltInVars, + DataQuery, + DataTransformerConfig, + EventBusSrv, + FieldConfigSource, + PanelPlugin, + PanelPluginDataSupport, + ScopedVars, + urlUtil, +} from '@grafana/data'; +import { EDIT_PANEL_ID } from 'app/core/constants'; +import config from 'app/core/config'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import { + PanelOptionsChangedEvent, + PanelQueriesChangedEvent, + PanelTransformationsChangedEvent, + RefreshEvent, + RenderEvent, +} from 'app/types/events'; +import { getTimeSrv } from '../services/TimeSrv'; +import { getVariablesUrlParams } from '../../variables/getAllVariableValuesForUrl'; +import { + filterFieldConfigOverrides, + getPanelOptionsWithDefaults, + isStandardFieldProp, + restoreCustomOverrideRules, +} from './getPanelOptionsWithDefaults'; +import { QueryGroupOptions } from 'app/types'; +import { PanelModelLibraryPanel } from '../../library-panels/types'; + +export interface GridPos { + x: number; + y: number; + w: number; + h: number; + static?: boolean; +} + +const notPersistedProperties: { [str: string]: boolean } = { + events: true, + isViewing: true, + isEditing: true, + isInView: true, + hasRefreshed: true, + cachedPluginOptions: true, + plugin: true, + queryRunner: true, + replaceVariables: true, + editSourceId: true, + configRev: true, + getDisplayTitle: true, + dataSupport: true, +}; + +// For angular panels we need to clean up properties when changing type +// To make sure the change happens without strange bugs happening when panels use same +// named property with different type / value expectations +// This is not required for react panels +const mustKeepProps: { [str: string]: boolean } = { + id: true, + gridPos: true, + type: true, + title: true, + scopedVars: true, + repeat: true, + repeatIteration: true, + repeatPanelId: true, + repeatDirection: true, + repeatedByRow: true, + minSpan: true, + collapsed: true, + panels: true, + targets: true, + datasource: true, + timeFrom: true, + timeShift: true, + hideTimeOverride: true, + description: true, + links: true, + fullscreen: true, + isEditing: true, + hasRefreshed: true, + events: true, + cacheTimeout: true, + cachedPluginOptions: true, + transparent: true, + pluginVersion: true, + queryRunner: true, + transformations: true, + fieldConfig: true, + editSourceId: true, + maxDataPoints: true, + interval: true, + replaceVariables: true, + libraryPanel: true, + getDisplayTitle: true, + configRev: true, +}; + +const defaults: any = { + gridPos: { x: 0, y: 0, h: 3, w: 6 }, + targets: [{ refId: 'A' }], + cachedPluginOptions: {}, + transparent: false, + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + datasource: null, + title: '', +}; + +export class PanelModel implements DataConfigSource { + /* persisted id, used in URL to identify a panel */ + id!: number; + editSourceId?: number; + gridPos!: GridPos; + type!: string; + title!: string; + alert?: any; + scopedVars?: ScopedVars; + repeat?: string; + repeatIteration?: number; + repeatPanelId?: number; + repeatDirection?: string; + repeatedByRow?: boolean; + maxPerRow?: number; + collapsed?: boolean; + + panels?: any; + declare targets: DataQuery[]; + transformations?: DataTransformerConfig[]; + datasource: string | null = null; + thresholds?: any; + pluginVersion?: string; + + snapshotData?: DataFrameDTO[]; + timeFrom?: any; + timeShift?: any; + hideTimeOverride?: any; + declare options: { + [key: string]: any; + }; + declare fieldConfig: FieldConfigSource; + + maxDataPoints?: number | null; + interval?: string | null; + description?: string; + links?: DataLink[]; + declare transparent: boolean; + + libraryPanel?: { uid: undefined; name: string } | PanelModelLibraryPanel; + + // non persisted + isViewing = false; + isEditing = false; + isInView = false; + configRev = 0; // increments when configs change + hasRefreshed?: boolean; + cacheTimeout?: any; + cachedPluginOptions: Record = {}; + legend?: { show: boolean; sort?: string; sortDesc?: boolean }; + plugin?: PanelPlugin; + + /** + * The PanelModel event bus only used for internal and legacy angular support. + * The EventBus passed to panels is based on the dashboard event model. + */ + events: EventBusSrv; + + private queryRunner?: PanelQueryRunner; + + constructor(model: any) { + this.events = new EventBusSrv(); + this.restoreModel(model); + this.replaceVariables = this.replaceVariables.bind(this); + } + + /** Given a persistened PanelModel restores property values */ + restoreModel(model: any) { + // Start with clean-up + for (const property in this) { + if (notPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + if (model[property]) { + continue; + } + + if (typeof (this as any)[property] === 'function') { + continue; + } + + if (typeof (this as any)[property] === 'symbol') { + continue; + } + + delete (this as any)[property]; + } + + // copy properties from persisted model + for (const property in model) { + (this as any)[property] = model[property]; + } + + // defaults + defaultsDeep(this, cloneDeep(defaults)); + + // queries must have refId + this.ensureQueryIds(); + } + + ensureQueryIds() { + if (this.targets && isArray(this.targets)) { + for (const query of this.targets) { + if (!query.refId) { + query.refId = getNextRefIdChar(this.targets); + } + } + } + } + + getOptions() { + return this.options; + } + + get hasChanged(): boolean { + return this.configRev > 0; + } + + updateOptions(options: object) { + this.options = options; + this.configRev++; + this.events.publish(new PanelOptionsChangedEvent()); + this.render(); + } + + updateFieldConfig(config: FieldConfigSource) { + this.fieldConfig = config; + this.configRev++; + this.events.publish(new PanelOptionsChangedEvent()); + + this.resendLastResult(); + this.render(); + } + + getSaveModel() { + const model: any = {}; + + for (const property in this) { + if (notPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + if (isEqual(this[property], defaults[property])) { + continue; + } + + model[property] = cloneDeep(this[property]); + } + + if (model.datasource === undefined) { + // This is part of defaults as defaults are removed in save model and + // this should not be removed in save model as exporter needs to templatize it + model.datasource = null; + } + + return model; + } + + setIsViewing(isViewing: boolean) { + this.isViewing = isViewing; + } + + updateGridPos(newPos: GridPos) { + this.gridPos.x = newPos.x; + this.gridPos.y = newPos.y; + this.gridPos.w = newPos.w; + this.gridPos.h = newPos.h; + } + + refresh() { + this.hasRefreshed = true; + this.events.publish(new RefreshEvent()); + } + + render() { + if (!this.hasRefreshed) { + this.refresh(); + } else { + this.events.publish(new RenderEvent()); + } + } + + private getOptionsToRemember() { + return Object.keys(this).reduce((acc, property) => { + if (notPersistedProperties[property] || mustKeepProps[property]) { + return acc; + } + return { + ...acc, + [property]: (this as any)[property], + }; + }, {}); + } + + private restorePanelOptions(pluginId: string) { + const prevOptions = this.cachedPluginOptions[pluginId]; + + if (!prevOptions) { + return; + } + + Object.keys(prevOptions.properties).map((property) => { + (this as any)[property] = prevOptions.properties[property]; + }); + + this.fieldConfig = restoreCustomOverrideRules(this.fieldConfig, prevOptions.fieldConfig); + } + + applyPluginOptionDefaults(plugin: PanelPlugin, isAfterPluginChange: boolean) { + const options = getPanelOptionsWithDefaults({ + plugin, + currentOptions: this.options, + currentFieldConfig: this.fieldConfig, + isAfterPluginChange: isAfterPluginChange, + }); + + this.fieldConfig = options.fieldConfig; + this.options = options.options; + } + + pluginLoaded(plugin: PanelPlugin) { + this.plugin = plugin; + const version = getPluginVersion(plugin); + + if (plugin.onPanelMigration) { + if (version !== this.pluginVersion) { + this.options = plugin.onPanelMigration(this); + this.pluginVersion = version; + } + } + + this.applyPluginOptionDefaults(plugin, false); + this.resendLastResult(); + } + + clearPropertiesBeforePluginChange() { + // remove panel type specific options + for (const key of keys(this)) { + if (mustKeepProps[key]) { + continue; + } + delete (this as any)[key]; + } + + this.options = {}; + + // clear custom options + this.fieldConfig = { + defaults: { + ...this.fieldConfig.defaults, + custom: {}, + }, + // filter out custom overrides + overrides: filterFieldConfigOverrides(this.fieldConfig.overrides, isStandardFieldProp), + }; + } + + changePlugin(newPlugin: PanelPlugin) { + const pluginId = newPlugin.meta.id; + const oldOptions: any = this.getOptionsToRemember(); + const prevFieldConfig = this.fieldConfig; + const oldPluginId = this.type; + const wasAngular = this.isAngularPlugin(); + this.cachedPluginOptions[oldPluginId] = { + properties: oldOptions, + fieldConfig: prevFieldConfig, + }; + + this.clearPropertiesBeforePluginChange(); + this.restorePanelOptions(pluginId); + + // Let panel plugins inspect options from previous panel and keep any that it can use + if (newPlugin.onPanelTypeChanged) { + const prevOptions = wasAngular ? { angular: oldOptions } : oldOptions.options; + Object.assign(this.options, newPlugin.onPanelTypeChanged(this, oldPluginId, prevOptions, prevFieldConfig)); + } + + // switch + this.type = pluginId; + this.plugin = newPlugin; + this.configRev++; + + // For some reason I need to rebind replace variables here, otherwise the viz repeater does not work + this.replaceVariables = this.replaceVariables.bind(this); + this.applyPluginOptionDefaults(newPlugin, true); + + if (newPlugin.onPanelMigration) { + this.pluginVersion = getPluginVersion(newPlugin); + } + } + + updateQueries(options: QueryGroupOptions) { + this.datasource = options.dataSource.default ? null : options.dataSource.name!; + this.timeFrom = options.timeRange?.from; + this.timeShift = options.timeRange?.shift; + this.hideTimeOverride = options.timeRange?.hide; + this.interval = options.minInterval; + this.maxDataPoints = options.maxDataPoints; + this.targets = options.queries; + this.configRev++; + + this.events.publish(new PanelQueriesChangedEvent()); + } + + addQuery(query?: Partial) { + query = query || { refId: 'A' }; + query.refId = getNextRefIdChar(this.targets); + this.targets.push(query as DataQuery); + this.configRev++; + } + + changeQuery(query: DataQuery, index: number) { + // ensure refId is maintained + query.refId = this.targets[index].refId; + this.configRev++; + + // update query in array + this.targets = this.targets.map((item, itemIndex) => { + if (itemIndex === index) { + return query; + } + return item; + }); + } + + getEditClone() { + const sourceModel = this.getSaveModel(); + + // Temporary id for the clone, restored later in redux action when changes are saved + sourceModel.id = EDIT_PANEL_ID; + sourceModel.editSourceId = this.id; + + const clone = new PanelModel(sourceModel); + clone.isEditing = true; + const sourceQueryRunner = this.getQueryRunner(); + + // Copy last query result + clone.getQueryRunner().useLastResultFrom(sourceQueryRunner); + + return clone; + } + + getTransformations() { + return this.transformations; + } + + getFieldOverrideOptions() { + if (!this.plugin) { + return undefined; + } + + return { + fieldConfig: this.fieldConfig, + replaceVariables: this.replaceVariables, + fieldConfigRegistry: this.plugin.fieldConfigRegistry, + theme: config.theme2, + }; + } + + getDataSupport(): PanelPluginDataSupport { + return this.plugin?.dataSupport ?? { annotations: false, alertStates: false }; + } + + getQueryRunner(): PanelQueryRunner { + if (!this.queryRunner) { + this.queryRunner = new PanelQueryRunner(this); + } + return this.queryRunner; + } + + hasTitle() { + return this.title && this.title.length > 0; + } + + isAngularPlugin(): boolean { + return (this.plugin && this.plugin.angularPanelCtrl) !== undefined; + } + + destroy() { + this.events.removeAllListeners(); + + if (this.queryRunner) { + this.queryRunner.destroy(); + } + } + + setTransformations(transformations: DataTransformerConfig[]) { + this.transformations = transformations; + this.resendLastResult(); + this.configRev++; + this.events.publish(new PanelTransformationsChangedEvent()); + } + + setProperty(key: keyof this, value: any) { + this[key] = value; + this.configRev++; + + // Custom handling of repeat dependent options, handled here as PanelEditor can + // update one key at a time right now + if (key === 'repeat') { + if (this.repeat && !this.repeatDirection) { + this.repeatDirection = 'h'; + } else if (!this.repeat) { + delete this.repeatDirection; + delete this.maxPerRow; + } + } + } + + replaceVariables(value: string, extraVars: ScopedVars | undefined, format?: string | Function) { + let vars = this.scopedVars; + + if (extraVars) { + vars = vars ? { ...vars, ...extraVars } : extraVars; + } + + const allVariablesParams = getVariablesUrlParams(vars); + const variablesQuery = urlUtil.toUrlParams(allVariablesParams); + const timeRangeUrl = urlUtil.toUrlParams(getTimeSrv().timeRangeForUrl()); + + vars = { + ...vars, + [DataLinkBuiltInVars.keepTime]: { + text: timeRangeUrl, + value: timeRangeUrl, + }, + [DataLinkBuiltInVars.includeVars]: { + text: variablesQuery, + value: variablesQuery, + }, + }; + + return getTemplateSrv().replace(value, vars, format); + } + + resendLastResult() { + if (!this.plugin) { + return; + } + + this.getQueryRunner().resendLastResult(); + } + + /* + * Panel have a different id while in edit mode (to more easily be able to discard changes) + * Use this to always get the underlying source id + * */ + getSavedId(): number { + return this.editSourceId ?? this.id; + } + + /* + * This is the title used when displaying the title in the UI so it will include any interpolated variables. + * If you need the raw title without interpolation use title property instead. + * */ + getDisplayTitle(): string { + return this.replaceVariables(this.title, {}, 'text'); + } +} + +function getPluginVersion(plugin: PanelPlugin): string { + return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version; +} + +interface PanelOptionsCache { + properties: any; + fieldConfig: FieldConfigSource; +} diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts new file mode 100644 index 0000000..05111cb --- /dev/null +++ b/public/app/features/dashboard/state/actions.ts @@ -0,0 +1,183 @@ +// Services & Utils +import { getBackendSrv } from '@grafana/runtime'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +// Actions +import { loadPluginDashboards } from '../../plugins/state/actions'; +import { + cleanUpDashboard, + loadDashboardPermissions, + panelModelAndPluginReady, + setPanelAngularComponent, +} from './reducers'; +import { notifyApp } from 'app/core/actions'; +import { loadPanelPlugin } from 'app/features/plugins/state/actions'; +// Types +import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel, ThunkResult } from 'app/types'; +import { PanelModel } from './PanelModel'; +import { cancelVariables } from '../../variables/state/actions'; +import { getPanelPluginNotFound } from '../dashgrid/PanelPluginError'; +import { getTimeSrv } from '../services/TimeSrv'; + +export function getDashboardPermissions(id: number): ThunkResult { + return async (dispatch) => { + const permissions = await getBackendSrv().get(`/api/dashboards/id/${id}/permissions`); + dispatch(loadDashboardPermissions(permissions)); + }; +} + +function toUpdateItem(item: DashboardAcl): DashboardAclUpdateDTO { + return { + userId: item.userId, + teamId: item.teamId, + role: item.role, + permission: item.permission, + }; +} + +export function updateDashboardPermission( + dashboardId: number, + itemToUpdate: DashboardAcl, + level: PermissionLevel +): ThunkResult { + return async (dispatch, getStore) => { + const { dashboard } = getStore(); + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited) { + continue; + } + + const updated = toUpdateItem(item); + + // if this is the item we want to update, update it's permission + if (itemToUpdate === item) { + updated.permission = level; + } + + itemsToUpdate.push(updated); + } + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function removeDashboardPermission(dashboardId: number, itemToDelete: DashboardAcl): ThunkResult { + return async (dispatch, getStore) => { + const dashboard = getStore().dashboard; + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited || item === itemToDelete) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function addDashboardPermission(dashboardId: number, newItem: NewDashboardAclItem): ThunkResult { + return async (dispatch, getStore) => { + const { dashboard } = getStore(); + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + itemsToUpdate.push({ + userId: newItem.userId, + teamId: newItem.teamId, + role: newItem.role, + permission: newItem.permission, + }); + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function importDashboard(data: any, dashboardTitle: string): ThunkResult { + return async (dispatch) => { + await getBackendSrv().post('/api/dashboards/import', data); + dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle))); + dispatch(loadPluginDashboards()); + }; +} + +export function removeDashboard(uri: string): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`/api/dashboards/${uri}`); + dispatch(loadPluginDashboards()); + }; +} + +export function initDashboardPanel(panel: PanelModel): ThunkResult { + return async (dispatch, getStore) => { + let pluginToLoad = panel.type; + let plugin = getStore().plugins.panels[pluginToLoad]; + + if (!plugin) { + try { + plugin = await dispatch(loadPanelPlugin(pluginToLoad)); + } catch (e) { + // When plugin not found + plugin = getPanelPluginNotFound(pluginToLoad, pluginToLoad === 'row'); + } + } + + if (!panel.plugin) { + panel.pluginLoaded(plugin); + } + + dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); + }; +} + +export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkResult { + return async (dispatch, getStore) => { + // ignore action is no change + if (panel.type === pluginId) { + return; + } + + const store = getStore(); + let plugin = store.plugins.panels[pluginId]; + + if (!plugin) { + plugin = await dispatch(loadPanelPlugin(pluginId)); + } + + // clean up angular component (scope / ctrl state) + const angularComponent = store.dashboard.panels[panel.id].angularComponent; + if (angularComponent) { + angularComponent.destroy(); + dispatch(setPanelAngularComponent({ panelId: panel.id, angularComponent: null })); + } + + panel.changePlugin(plugin); + + dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); + }; +} + +export const cleanUpDashboardAndVariables = (): ThunkResult => (dispatch, getStore) => { + const store = getStore(); + const dashboard = store.dashboard.getModel(); + + if (dashboard) { + dashboard.destroy(); + } + + getTimeSrv().stopAutoRefresh(); + + dispatch(cleanUpDashboard()); + dispatch(cancelVariables()); +}; diff --git a/public/app/features/dashboard/state/analyticsProcessor.ts b/public/app/features/dashboard/state/analyticsProcessor.ts new file mode 100644 index 0000000..9834d86 --- /dev/null +++ b/public/app/features/dashboard/state/analyticsProcessor.ts @@ -0,0 +1,14 @@ +import { DashboardModel } from './DashboardModel'; +import { reportMetaAnalytics, MetaAnalyticsEventName, DashboardViewEventPayload } from '@grafana/runtime'; + +export function emitDashboardViewEvent(dashboard: DashboardModel) { + const eventData: DashboardViewEventPayload = { + dashboardId: dashboard.id, + dashboardName: dashboard.title, + dashboardUid: dashboard.uid, + folderName: dashboard.meta.folderTitle, + eventName: MetaAnalyticsEventName.DashboardView, + }; + + reportMetaAnalytics(eventData); +} diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts new file mode 100644 index 0000000..fdbdf90 --- /dev/null +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts @@ -0,0 +1,419 @@ +import { + ConfigOverrideRule, + FieldColorModeId, + FieldConfig, + FieldConfigProperty, + FieldConfigSource, + PanelPlugin, + standardEditorsRegistry, + standardFieldConfigEditorRegistry, + StandardOptionConfig, + ThresholdsMode, +} from '@grafana/data'; +import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; +import { mockStandardFieldConfigOptions } from 'test/helpers/fieldConfig'; +import { getPanelOptionsWithDefaults, restoreCustomOverrideRules } from './getPanelOptionsWithDefaults'; + +standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); +standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); + +const pluginA = getPanelPlugin({ id: 'graph' }); + +pluginA.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'Hide lines', + path: 'hideLines', + defaultValue: false, + }); + }, +}); + +pluginA.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + }); + builder.addTextInput({ + name: 'Name', + path: 'name', + defaultValue: 'hello', + }); + builder.addNumberInput({ + name: 'Number', + path: 'number', + defaultValue: 10, + }); +}); + +describe('getPanelOptionsWithDefaults', () => { + describe('When panel plugin has no options', () => { + it('Should set defaults', () => { + const result = runScenario({ + plugin: getPanelPlugin({ id: 'graph' }), + options: {}, + defaults: {}, + overrides: [], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object {}, + "overrides": Array [], + }, + "options": Object {}, + } + `); + }); + }); + + describe('When current options are emtpy', () => { + it('Should set defaults', () => { + const result = getPanelOptionsWithDefaults({ + plugin: pluginA, + currentOptions: {}, + currentFieldConfig: { + defaults: {}, + overrides: [], + }, + isAfterPluginChange: false, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object { + "hideLines": false, + }, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + }, + "overrides": Array [], + }, + "options": Object { + "name": "hello", + "number": 10, + "showThresholds": true, + }, + } + `); + }); + }); + + describe('When there are current options and overrides', () => { + it('Should set defaults', () => { + const result = getPanelOptionsWithDefaults({ + plugin: pluginA, + currentOptions: { + number: 20, + showThresholds: false, + }, + currentFieldConfig: { + defaults: { + unit: 'bytes', + decimals: 2, + }, + overrides: [], + }, + isAfterPluginChange: true, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object { + "hideLines": false, + }, + "decimals": 2, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + "unit": "bytes", + }, + "overrides": Array [], + }, + "options": Object { + "name": "hello", + "number": 20, + "showThresholds": false, + }, + } + `); + }); + }); + + describe('when changing panel type to one that does not support by value color mode', () => { + it('should change color mode', () => { + const plugin = getPanelPlugin({ id: 'graph' }).useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + }, + }, + }); + + const result = getPanelOptionsWithDefaults({ + plugin, + currentOptions: {}, + currentFieldConfig: { + defaults: { + color: { mode: FieldColorModeId.Thresholds }, + }, + overrides: [], + }, + isAfterPluginChange: true, + }); + + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); + }); + }); + + describe('when changing panel type from one not supporting by value color mode to one that supports it', () => { + it('should keep supported mode', () => { + const result = runScenario({ + defaults: { + color: { mode: FieldColorModeId.PaletteClassic }, + }, + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + }, + }, + }, + }); + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); + }); + + it('should change to thresholds mode when it prefers to', () => { + const result = runScenario({ + defaults: { + color: { mode: FieldColorModeId.PaletteClassic }, + }, + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + preferThresholdsMode: true, + }, + }, + }, + isAfterPluginChange: true, + }); + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.Thresholds); + }); + }); + + describe('when changing panel type to one that does not use standard field config', () => { + it('should clean defaults', () => { + const plugin = getPanelPlugin({ id: 'graph' }); + + const result = getPanelOptionsWithDefaults({ + plugin, + currentOptions: {}, + currentFieldConfig: { + defaults: { + color: { mode: FieldColorModeId.Thresholds }, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [], + }, + }, + overrides: [], + }, + isAfterPluginChange: true, + }); + + expect(result.fieldConfig.defaults.thresholds).toBeUndefined(); + }); + }); + + describe('when applying defaults clean properties that are no longer part of the registry', () => { + it('should remove custom defaults that no longer exist', () => { + const result = runScenario({ + defaults: { + unit: 'bytes', + custom: { + customProp: 20, + customPropNoExist: true, + nested: { + nestedA: 'A', + nestedB: 'B', + }, + }, + }, + }); + + expect(result.fieldConfig.defaults).toMatchInlineSnapshot(` + Object { + "custom": Object { + "customProp": 20, + "nested": Object { + "nestedA": "A", + }, + }, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + "unit": "bytes", + } + `); + }); + + it('should remove custom overrides that no longer exist', () => { + const result = runScenario({ + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customPropNoExist', + value: 'google', + }, + ], + }, + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customProp', + value: 30, + }, + ], + }, + ], + }); + + expect(result.fieldConfig.overrides.length).toBe(1); + expect(result.fieldConfig.overrides[0].properties[0].id).toBe('custom.customProp'); + }); + }); +}); + +describe('restoreCustomOverrideRules', () => { + it('should add back custom rules', () => { + const current = { + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'SeriesA' }, + properties: [ + { + id: 'decimals', + value: 2, + }, + ], + }, + ], + }; + const old = { + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'SeriesA' }, + properties: [ + { + id: 'custom.propName', + value: 10, + }, + ], + }, + { + matcher: { id: 'byName', options: 'SeriesB' }, + properties: [ + { + id: 'custom.propName', + value: 20, + }, + ], + }, + ], + }; + + const result = restoreCustomOverrideRules(current, old); + expect(result.overrides.length).toBe(2); + expect(result.overrides[0].properties[0].id).toBe('decimals'); + expect(result.overrides[0].properties[1].id).toBe('custom.propName'); + expect(result.overrides[1].properties.length).toBe(1); + expect(result.overrides[1].matcher.options).toBe('SeriesB'); + }); +}); + +interface ScenarioOptions { + defaults?: FieldConfig; + overrides?: ConfigOverrideRule[]; + disabledStandardOptions?: FieldConfigProperty[]; + standardOptions?: Partial>; + plugin?: PanelPlugin; + options?: any; + isAfterPluginChange?: boolean; +} + +function runScenario(options: ScenarioOptions) { + const fieldConfig: FieldConfigSource = { + defaults: options.defaults || {}, + overrides: options.overrides || [], + }; + + const plugin = + options.plugin ?? + getPanelPlugin({ id: 'graph' }).useFieldConfig({ + standardOptions: options.standardOptions, + useCustomConfig: (builder) => { + builder.addNumberInput({ + name: 'Custom prop', + path: 'customProp', + defaultValue: 10, + }); + builder.addTextInput({ + name: 'Nested prop', + path: 'nested.nestedA', + }); + }, + }); + + return getPanelOptionsWithDefaults({ + plugin, + currentOptions: options.options || {}, + currentFieldConfig: fieldConfig, + isAfterPluginChange: !!options.isAfterPluginChange, + }); +} diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts new file mode 100644 index 0000000..2bf513a --- /dev/null +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts @@ -0,0 +1,218 @@ +import { + ConfigOverrideRule, + DynamicConfigValue, + FieldColorConfigSettings, + FieldColorModeId, + fieldColorModeRegistry, + FieldConfigOptionsRegistry, + FieldConfigProperty, + FieldConfigSource, + PanelPlugin, + ThresholdsConfig, + ThresholdsMode, +} from '@grafana/data'; +import { mergeWith, isArray, isObject, unset, isEqual } from 'lodash'; + +export interface Props { + plugin: PanelPlugin; + currentFieldConfig: FieldConfigSource; + currentOptions: Record; + isAfterPluginChange: boolean; +} + +export interface OptionDefaults { + options: any; + fieldConfig: FieldConfigSource; +} + +export function getPanelOptionsWithDefaults({ + plugin, + currentOptions, + currentFieldConfig, + isAfterPluginChange, +}: Props): OptionDefaults { + const optionsWithDefaults = mergeWith( + {}, + plugin.defaults, + currentOptions || {}, + (objValue: any, srcValue: any): any => { + if (isArray(srcValue)) { + return srcValue; + } + } + ); + + const fieldConfigWithDefaults = applyFieldConfigDefaults(currentFieldConfig, plugin); + const fieldConfigWithOptimalColorMode = adaptFieldColorMode(plugin, fieldConfigWithDefaults, isAfterPluginChange); + + return { options: optionsWithDefaults, fieldConfig: fieldConfigWithOptimalColorMode }; +} + +function applyFieldConfigDefaults(existingFieldConfig: FieldConfigSource, plugin: PanelPlugin): FieldConfigSource { + const pluginDefaults = plugin.fieldConfigDefaults; + + const result: FieldConfigSource = { + defaults: mergeWith( + {}, + pluginDefaults.defaults, + existingFieldConfig ? existingFieldConfig.defaults : {}, + (objValue: any, srcValue: any): any => { + if (isArray(srcValue)) { + return srcValue; + } + } + ), + overrides: existingFieldConfig?.overrides ?? [], + }; + + cleanProperties(result.defaults, '', plugin.fieldConfigRegistry); + + // Thresholds base values are null in JSON but need to be converted to -Infinity + if (result.defaults.thresholds) { + fixThresholds(result.defaults.thresholds); + } + + // Filter out overrides for properties that cannot be found in registry + result.overrides = filterFieldConfigOverrides(result.overrides, (prop) => { + return plugin.fieldConfigRegistry.getIfExists(prop.id) !== undefined; + }); + + for (const override of result.overrides) { + for (const property of override.properties) { + if (property.id === 'thresholds') { + fixThresholds(property.value as ThresholdsConfig); + } + } + } + + return result; +} + +export function filterFieldConfigOverrides( + overrides: ConfigOverrideRule[], + condition: (value: DynamicConfigValue) => boolean +): ConfigOverrideRule[] { + return overrides + .map((x) => { + const properties = x.properties.filter(condition); + + return { + ...x, + properties, + }; + }) + .filter((x) => x.properties.length > 0); +} + +function cleanProperties(obj: any, parentPath: string, fieldConfigRegistry: FieldConfigOptionsRegistry) { + let found = false; + + for (const propName of Object.keys(obj)) { + const value = obj[propName]; + const fullPath = `${parentPath}${propName}`; + const existsInRegistry = !!fieldConfigRegistry.getIfExists(fullPath); + + // need to check early here as some standard properties have nested properies + if (existsInRegistry) { + found = true; + continue; + } + + if (isArray(value) || !isObject(value)) { + if (!existsInRegistry) { + unset(obj, propName); + } + } else { + const childPropFound = cleanProperties(value, `${fullPath}.`, fieldConfigRegistry); + // If no child props found unset the main object + if (!childPropFound) { + unset(obj, propName); + } + } + } + + return found; +} + +function adaptFieldColorMode( + plugin: PanelPlugin, + fieldConfig: FieldConfigSource, + isAfterPluginChange: boolean +): FieldConfigSource { + if (!isAfterPluginChange) { + return fieldConfig; + } + + // adjust to prefered field color setting if needed + const color = plugin.fieldConfigRegistry.getIfExists(FieldConfigProperty.Color); + + if (color && color.settings) { + const colorSettings = color.settings as FieldColorConfigSettings; + const mode = fieldColorModeRegistry.getIfExists(fieldConfig.defaults.color?.mode); + + // When no support fo value colors, use classic palette + if (!colorSettings.byValueSupport) { + if (!mode || mode.isByValue) { + fieldConfig.defaults.color = { mode: FieldColorModeId.PaletteClassic }; + return fieldConfig; + } + } + + // When supporting value colors and prefering thresholds, use Thresholds mode. + // Otherwise keep current mode + if (colorSettings.byValueSupport && colorSettings.preferThresholdsMode) { + if (!mode || !mode.isByValue) { + fieldConfig.defaults.color = { mode: FieldColorModeId.Thresholds }; + return fieldConfig; + } + } + } + return fieldConfig; +} + +function fixThresholds(thresholds: ThresholdsConfig) { + if (!thresholds.mode) { + thresholds.mode = ThresholdsMode.Absolute; + } + + if (!thresholds.steps) { + thresholds.steps = []; + } else if (thresholds.steps.length) { + // First value is always -Infinity + // JSON saves it as null + thresholds.steps[0].value = -Infinity; + } +} + +export function restoreCustomOverrideRules(current: FieldConfigSource, old: FieldConfigSource): FieldConfigSource { + const result = { + defaults: { + ...current.defaults, + custom: old.defaults.custom, + }, + overrides: [...current.overrides], + }; + + for (const override of old.overrides) { + for (const prop of override.properties) { + if (isCustomFieldProp(prop)) { + const currentOverride = result.overrides.find((o) => isEqual(o.matcher, override.matcher)); + if (currentOverride) { + currentOverride.properties.push(prop); + } else { + result.overrides.push(override); + } + } + } + } + + return result; +} + +export function isCustomFieldProp(prop: DynamicConfigValue): boolean { + return prop.id.startsWith('custom.'); +} + +export function isStandardFieldProp(prop: DynamicConfigValue): boolean { + return !isCustomFieldProp(prop); +} diff --git a/public/app/features/dashboard/state/index.ts b/public/app/features/dashboard/state/index.ts new file mode 100644 index 0000000..253d4aa --- /dev/null +++ b/public/app/features/dashboard/state/index.ts @@ -0,0 +1,2 @@ +export { DashboardModel } from './DashboardModel'; +export { PanelModel } from './PanelModel'; diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts new file mode 100644 index 0000000..470c4ad --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -0,0 +1,296 @@ +import { Subject } from 'rxjs'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { locationService, setEchoSrv } from '@grafana/runtime'; + +import { initDashboard, InitDashboardArgs } from './initDashboard'; +import { DashboardInitPhase, DashboardRoutes } from 'app/types'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { dashboardInitCompleted, dashboardInitFetching, dashboardInitServices } from './reducers'; +import { Echo } from '../../../core/services/echo/Echo'; +import { variableAdapters } from 'app/features/variables/adapters'; +import { createConstantVariableAdapter } from 'app/features/variables/constant/adapter'; +import { constantBuilder } from 'app/features/variables/shared/testing/builders'; +import { TransactionStatus, variablesInitTransaction } from '../../variables/state/transactionReducer'; +import { keybindingSrv } from 'app/core/services/keybindingSrv'; +import { getTimeSrv, setTimeSrv } from '../services/TimeSrv'; +import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv'; +import { getDashboardSrv, setDashboardSrv } from '../services/DashboardSrv'; +import { + getDashboardQueryRunner, + setDashboardQueryRunnerFactory, +} from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { emptyResult } from '../../query/state/DashboardQueryRunner/utils'; + +jest.mock('app/core/services/backend_srv'); +jest.mock('app/features/dashboard/services/TimeSrv', () => { + const original = jest.requireActual('app/features/dashboard/services/TimeSrv'); + return { + ...original, + getTimeSrv: () => ({ + ...original.getTimeSrv(), + timeRange: jest.fn().mockReturnValue(undefined), + }), + }; +}); +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + user: { orgId: 1, orgName: 'TestOrg' }, + }, +})); +jest.mock('app/features/dashboard/services/ChangeTracker'); + +variableAdapters.register(createConstantVariableAdapter()); +const mockStore = configureMockStore([thunk]); + +interface ScenarioContext { + args: InitDashboardArgs; + loaderSrv: any; + backendSrv: any; + setup: (fn: () => void) => void; + actions: any[]; + storeState: any; +} + +type ScenarioFn = (ctx: ScenarioContext) => void; + +function describeInitScenario(description: string, scenarioFn: ScenarioFn) { + describe(description, () => { + const loaderSrv = { + loadDashboard: jest.fn(() => ({ + meta: { + canStar: false, + canShare: false, + isNew: true, + folderId: 0, + }, + dashboard: { + title: 'My cool dashboard', + panels: [ + { + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 9 }, + title: 'Panel Title', + id: 2, + targets: [ + { + refId: 'A', + expr: 'old expr', + }, + ], + }, + ], + templating: { + list: [constantBuilder().build()], + }, + }, + })), + }; + + setDashboardLoaderSrv((loaderSrv as unknown) as DashboardLoaderSrv); + setDashboardQueryRunnerFactory(() => ({ + getResult: emptyResult, + run: jest.fn(), + cancel: () => undefined, + cancellations: () => new Subject(), + destroy: () => undefined, + })); + + let setupFn = () => {}; + + const ctx: ScenarioContext = { + args: { + urlUid: 'DGmvKKxZz', + fixUrl: false, + routeName: DashboardRoutes.Normal, + }, + backendSrv: getBackendSrv(), + loaderSrv, + actions: [], + storeState: { + location: { + query: {}, + }, + dashboard: { + initPhase: DashboardInitPhase.Services, + }, + user: {}, + explore: { + left: { + originPanelId: undefined, + queries: [], + }, + }, + templating: { + variables: {}, + transaction: { uid: 'DGmvKKxZz', status: TransactionStatus.Completed }, + }, + }, + setup: (fn: () => void) => { + setupFn = fn; + }, + }; + + beforeEach(async () => { + keybindingSrv.setupDashboardBindings = jest.fn(); + + setDashboardSrv({ + setCurrent: jest.fn(), + } as any); + + setTimeSrv({ + init: jest.fn(), + } as any); + + setupFn(); + setEchoSrv(new Echo()); + + const store = mockStore(ctx.storeState); + // @ts-ignore + await store.dispatch(initDashboard(ctx.args)); + + ctx.actions = store.getActions(); + }); + + scenarioFn(ctx); + }); +} + +describeInitScenario('Initializing new dashboard', (ctx) => { + ctx.setup(() => { + ctx.storeState.user.orgId = 12; + ctx.args.routeName = DashboardRoutes.New; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should update location with orgId query param', () => { + const search = locationService.getSearch(); + expect(search.get('orgId')).toBe('12'); + }); + + it('Should send action dashboardInitCompleted', () => { + expect(ctx.actions[7].type).toBe(dashboardInitCompleted.type); + expect(ctx.actions[7].payload.title).toBe('New dashboard'); + }); + + it('Should initialize services', () => { + expect(getTimeSrv().init).toBeCalled(); + expect(getDashboardSrv().setCurrent).toBeCalled(); + expect(getDashboardQueryRunner().run).toBeCalled(); + expect(keybindingSrv.setupDashboardBindings).toBeCalled(); + }); +}); + +describeInitScenario('Initializing home dashboard', (ctx) => { + ctx.setup(() => { + ctx.args.routeName = DashboardRoutes.Home; + ctx.backendSrv.get.mockResolvedValue({ + redirectUri: '/u/123/my-home', + }); + }); + + it('Should redirect to custom home dashboard', () => { + const location = locationService.getLocation(); + expect(location.pathname).toBe('/u/123/my-home'); + }); +}); + +describeInitScenario('Initializing home dashboard cancelled', (ctx) => { + ctx.setup(() => { + ctx.args.routeName = DashboardRoutes.Home; + ctx.backendSrv.get.mockRejectedValue({ cancelled: true }); + }); + + it('Should abort init process', () => { + expect(ctx.actions.length).toBe(1); + }); +}); + +describeInitScenario('Initializing existing dashboard', (ctx) => { + const mockQueries = [ + { + context: 'explore', + key: 'jdasldsa98dsa9', + refId: 'A', + expr: 'new expr', + }, + { + context: 'explore', + key: 'fdsjkfds78fd', + refId: 'B', + }, + ]; + + ctx.setup(() => { + ctx.storeState.user.orgId = 12; + ctx.storeState.explore.left.originPanelId = 2; + ctx.storeState.explore.left.queries = mockQueries; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should update location with orgId query param', () => { + const search = locationService.getSearch(); + expect(search.get('orgId')).toBe('12'); + }); + + it('Should send action dashboardInitCompleted', () => { + expect(ctx.actions[8].type).toBe(dashboardInitCompleted.type); + expect(ctx.actions[8].payload.title).toBe('My cool dashboard'); + }); + + it('Should initialize services', () => { + expect(getTimeSrv().init).toBeCalled(); + expect(getDashboardSrv().setCurrent).toBeCalled(); + expect(getDashboardQueryRunner().run).toBeCalled(); + expect(keybindingSrv.setupDashboardBindings).toBeCalled(); + }); + + it('Should initialize redux variables if newVariables is enabled', () => { + expect(ctx.actions[2].type).toBe(variablesInitTransaction.type); + }); +}); + +describeInitScenario('Initializing previously canceled dashboard initialization', (ctx) => { + ctx.setup(() => { + ctx.storeState.dashboard.initPhase = DashboardInitPhase.Fetching; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should not send action dashboardInitCompleted', () => { + const dashboardInitCompletedAction = ctx.actions.find((a) => { + return a.type === dashboardInitCompleted.type; + }); + expect(dashboardInitCompletedAction).toBe(undefined); + }); + + it('Should initialize timeSrv and dashboard query runner', () => { + expect(getTimeSrv().init).toBeCalled(); + expect(getDashboardQueryRunner().run).toBeCalled(); + }); + + it('Should not initialize other services', () => { + expect(getDashboardSrv().setCurrent).not.toBeCalled(); + expect(keybindingSrv.setupDashboardBindings).not.toBeCalled(); + }); +}); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts new file mode 100644 index 0000000..03419a2 --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -0,0 +1,282 @@ +// Services & Utils +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { keybindingSrv } from 'app/core/services/keybindingSrv'; +// Actions +import { notifyApp } from 'app/core/actions'; +import { + clearDashboardQueriesToUpdateOnLoad, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitFetching, + dashboardInitServices, + dashboardInitSlow, +} from './reducers'; +// Types +import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types'; +import { DashboardModel } from './DashboardModel'; +import { DataQuery, locationUtil } from '@grafana/data'; +import { initVariablesTransaction } from '../../variables/state/actions'; +import { emitDashboardViewEvent } from './analyticsProcessor'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { locationService } from '@grafana/runtime'; +import { ChangeTracker } from '../services/ChangeTracker'; +import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; + +export interface InitDashboardArgs { + urlUid?: string; + urlSlug?: string; + urlType?: string; + urlFolderId?: string | null; + routeName?: string; + fixUrl: boolean; +} + +async function redirectToNewUrl(slug: string) { + const res = await backendSrv.getDashboardBySlug(slug); + + if (res) { + const location = locationService.getLocation(); + let newUrl = res.meta.url; + + // fix solo route urls + if (location.pathname.indexOf('dashboard-solo') !== -1) { + newUrl = newUrl.replace('/d/', '/d-solo/'); + } + + const url = locationUtil.stripBaseFromUrl(newUrl); + locationService.replace(url); + } +} + +async function fetchDashboard( + args: InitDashboardArgs, + dispatch: ThunkDispatch, + getState: () => StoreState +): Promise { + try { + switch (args.routeName) { + case DashboardRoutes.Home: { + // load home dash + const dashDTO: DashboardDTO = await backendSrv.get('/api/dashboards/home'); + + // if user specified a custom home dashboard redirect to that + if (dashDTO.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); + locationService.replace(newUrl); + return null; + } + + // disable some actions on the default home dashboard + dashDTO.meta.canSave = false; + dashDTO.meta.canShare = false; + dashDTO.meta.canStar = false; + return dashDTO; + } + case DashboardRoutes.Normal: { + // for old db routes we redirect + if (args.urlType === 'db') { + redirectToNewUrl(args.urlSlug!); + return null; + } + + const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); + + if (args.fixUrl && dashDTO.meta.url) { + // check if the current url is correct (might be old slug) + const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); + const currentPath = locationService.getLocation().pathname; + + if (dashboardUrl !== currentPath) { + // Spread current location to persist search params used for navigation + locationService.replace({ + ...locationService.getLocation(), + pathname: dashboardUrl, + }); + console.log('not correct url correcting', dashboardUrl, currentPath); + } + } + return dashDTO; + } + case DashboardRoutes.New: { + return getNewDashboardModelData(args.urlFolderId); + } + default: + throw { message: 'Unknown route ' + args.routeName }; + } + } catch (err) { + // Ignore cancelled errors + if (err.cancelled) { + return null; + } + + dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err })); + console.error(err); + return null; + } +} + +/** + * This action (or saga) does everything needed to bootstrap a dashboard & dashboard model. + * First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates) + * + * This is used both for single dashboard & solo panel routes, home & new dashboard routes. + * + * Then it handles the initializing of the old angular services that the dashboard components & panels still depend on + * + */ +export function initDashboard(args: InitDashboardArgs): ThunkResult { + return async (dispatch, getState) => { + // set fetching state + dispatch(dashboardInitFetching()); + + // Detect slow loading / initializing and set state flag + // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing + setTimeout(() => { + if (getState().dashboard.getModel() === null) { + dispatch(dashboardInitSlow()); + } + }, 500); + + // fetch dashboard data + const dashDTO = await fetchDashboard(args, dispatch, getState); + + // returns null if there was a redirect or error + if (!dashDTO) { + return; + } + + // set initializing state + dispatch(dashboardInitServices()); + + // create model + let dashboard: DashboardModel; + try { + dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + } catch (err) { + dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err })); + console.error(err); + return; + } + + // add missing orgId query param + const storeState = getState(); + const queryParams = locationService.getSearchObject(); + + if (!queryParams.orgId) { + // TODO this is currently not possible with the LocationService API + locationService.partial({ orgId: storeState.user.orgId }, true); + } + + // init services + const timeSrv: TimeSrv = getTimeSrv(); + const dashboardSrv: DashboardSrv = getDashboardSrv(); + const changeTracker = new ChangeTracker(); + + timeSrv.init(dashboard); + const runner = createDashboardQueryRunner({ dashboard, timeSrv }); + runner.run({ dashboard, range: timeSrv.timeRange() }); + + if (storeState.dashboard.modifiedQueries) { + const { panelId, queries } = storeState.dashboard.modifiedQueries; + dashboard.meta.fromExplore = !!(panelId && queries); + } + + // template values service needs to initialize completely before the rest of the dashboard can load + await dispatch(initVariablesTransaction(args.urlUid!, dashboard)); + + if (getState().templating.transaction.uid !== args.urlUid) { + // if a previous dashboard has slow running variable queries the batch uid will be the new one + // but the args.urlUid will be the same as before initVariablesTransaction was called so then we can't continue initializing + // the previous dashboard. + return; + } + + // If dashboard is in a different init phase it means it cancelled during service init + if (getState().dashboard.initPhase !== DashboardInitPhase.Services) { + return; + } + + try { + dashboard.processRepeats(); + + // handle auto fix experimental feature + if (queryParams.autofitpanels) { + dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk); + } + + changeTracker.init(dashboard, 2000); + keybindingSrv.setupDashboardBindings(dashboard); + } catch (err) { + dispatch(notifyApp(createErrorNotification('Dashboard init failed', err))); + console.error(err); + } + + if (storeState.dashboard.modifiedQueries) { + const { panelId, queries } = storeState.dashboard.modifiedQueries; + updateQueriesWhenComingFromExplore(dispatch, dashboard, panelId, queries); + } + + // legacy srv state + dashboardSrv.setCurrent(dashboard); + + // send open dashboard event + if (args.routeName !== DashboardRoutes.New) { + emitDashboardViewEvent(dashboard); + + // Listen for changes on the current dashboard + dashboardWatcher.watch(dashboard.uid); + } else { + dashboardWatcher.leave(); + } + + // yay we are done + dispatch(dashboardInitCompleted(dashboard)); + }; +} + +function getNewDashboardModelData(urlFolderId?: string | null): any { + const data = { + meta: { + canStar: false, + canShare: false, + isNew: true, + folderId: 0, + }, + dashboard: { + title: 'New dashboard', + panels: [ + { + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 9 }, + title: 'Panel Title', + }, + ], + }, + }; + + if (urlFolderId) { + data.meta.folderId = parseInt(urlFolderId, 10); + } + + return data; +} + +function updateQueriesWhenComingFromExplore( + dispatch: ThunkDispatch, + dashboard: DashboardModel, + originPanelId: number, + queries: DataQuery[] +) { + const panelArrId = dashboard.panels.findIndex((panel) => panel.id === originPanelId); + + if (panelArrId > -1) { + dashboard.panels[panelArrId].targets = queries; + } + + // Clear update state now that we're done + dispatch(clearDashboardQueriesToUpdateOnLoad()); +} diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts new file mode 100644 index 0000000..2674b32 --- /dev/null +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -0,0 +1,85 @@ +import { + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitFetching, + dashboardInitSlow, + loadDashboardPermissions, + dashboardReducer, + initialState, +} from './reducers'; +import { DashboardInitPhase, DashboardState, OrgRole, PermissionLevel } from 'app/types'; +import { DashboardModel } from './DashboardModel'; + +describe('dashboard reducer', () => { + describe('loadDashboardPermissions', () => { + let state: DashboardState; + + beforeEach(() => { + const action = loadDashboardPermissions([ + { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, + { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, + ]); + state = dashboardReducer(initialState, action); + }); + + it('should add permissions to state', async () => { + expect(state.permissions?.length).toBe(2); + }); + }); + + describe('dashboardInitCompleted', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitSlow()); + state = dashboardReducer( + state, + dashboardInitCompleted( + new DashboardModel({ + title: 'My dashboard', + panels: [{ id: 1 }, { id: 2 }], + }) + ) + ); + }); + + it('should set model', async () => { + expect(state.getModel()!.title).toBe('My dashboard'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + + it('should create panel state', async () => { + expect(state.panels['1']).toBeDefined(); + expect(state.panels['2']).toBeDefined(); + }); + }); + + describe('dashboardInitFailed', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitFailed({ message: 'Oh no', error: 'sad' })); + }); + + it('should set model', async () => { + expect(state.getModel()?.title).toBe('Dashboard init failed'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + + it('should set initError', async () => { + expect(state.initError?.message).toBe('Oh no'); + }); + + it('should set phase failed', async () => { + expect(state.initPhase).toBe(DashboardInitPhase.Failed); + }); + }); +}); diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts new file mode 100644 index 0000000..32f6e7f --- /dev/null +++ b/public/app/features/dashboard/state/reducers.ts @@ -0,0 +1,131 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + DashboardAclDTO, + DashboardInitError, + DashboardInitPhase, + DashboardState, + PanelState, + QueriesToUpdateOnDashboardLoad, +} from 'app/types'; +import { AngularComponent } from '@grafana/runtime'; +import { EDIT_PANEL_ID } from 'app/core/constants'; +import { processAclItems } from 'app/core/utils/acl'; +import { panelEditorReducer } from '../components/PanelEditor/state/reducers'; +import { DashboardModel } from './DashboardModel'; +import { PanelModel } from './PanelModel'; +import { PanelPlugin } from '@grafana/data'; + +export const initialState: DashboardState = { + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + getModel: () => null, + permissions: [], + modifiedQueries: null, + panels: {}, + initError: null, +}; + +const dashbardSlice = createSlice({ + name: 'dashboard', + initialState, + reducers: { + loadDashboardPermissions: (state, action: PayloadAction) => { + state.permissions = processAclItems(action.payload); + }, + dashboardInitFetching: (state, action: PayloadAction) => { + state.initPhase = DashboardInitPhase.Fetching; + }, + dashboardInitServices: (state, action: PayloadAction) => { + state.initPhase = DashboardInitPhase.Services; + }, + dashboardInitSlow: (state, action: PayloadAction) => { + state.isInitSlow = true; + }, + dashboardInitCompleted: (state, action: PayloadAction) => { + state.getModel = () => action.payload; + state.initPhase = DashboardInitPhase.Completed; + state.isInitSlow = false; + + for (const panel of action.payload.panels) { + state.panels[panel.id] = { + pluginId: panel.type, + }; + } + }, + dashboardInitFailed: (state, action: PayloadAction) => { + state.initPhase = DashboardInitPhase.Failed; + state.initError = action.payload; + state.getModel = () => { + return new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }); + }; + }, + cleanUpDashboard: (state, action: PayloadAction) => { + state.panels = {}; + state.initPhase = DashboardInitPhase.NotStarted; + state.isInitSlow = false; + state.initError = null; + state.getModel = () => null; + }, + setDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction) => { + state.modifiedQueries = action.payload; + }, + clearDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction) => { + state.modifiedQueries = null; + }, + panelModelAndPluginReady: (state: DashboardState, action: PayloadAction) => { + updatePanelState(state, action.payload.panelId, { plugin: action.payload.plugin }); + }, + cleanUpEditPanel: (state, action: PayloadAction) => { + // TODO: refactor, since the state should be mutated by copying only + delete state.panels[EDIT_PANEL_ID]; + }, + setPanelAngularComponent: (state: DashboardState, action: PayloadAction) => { + updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent }); + }, + addPanel: (state, action: PayloadAction) => { + // TODO: refactor, since the state should be mutated by copying only + state.panels[action.payload.id] = { pluginId: action.payload.type }; + }, + }, +}); + +export function updatePanelState(state: DashboardState, panelId: number, ps: Partial) { + if (!state.panels[panelId]) { + state.panels[panelId] = ps as PanelState; + } else { + Object.assign(state.panels[panelId], ps); + } +} + +export interface PanelModelAndPluginReadyPayload { + panelId: number; + plugin: PanelPlugin; +} + +export interface SetPanelAngularComponentPayload { + panelId: number; + angularComponent: AngularComponent | null; +} + +export const { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitFailed, + dashboardInitSlow, + dashboardInitCompleted, + dashboardInitServices, + cleanUpDashboard, + setDashboardQueriesToUpdateOnLoad, + clearDashboardQueriesToUpdateOnLoad, + panelModelAndPluginReady, + addPanel, + cleanUpEditPanel, + setPanelAngularComponent, +} = dashbardSlice.actions; + +export const dashboardReducer = dashbardSlice.reducer; + +export default { + dashboard: dashboardReducer, + panelEditor: panelEditorReducer, +}; diff --git a/public/app/features/dashboard/state/selectors.ts b/public/app/features/dashboard/state/selectors.ts new file mode 100644 index 0000000..1f2fd77 --- /dev/null +++ b/public/app/features/dashboard/state/selectors.ts @@ -0,0 +1,9 @@ +import { DashboardState, PanelState } from 'app/types'; + +export function getPanelStateById(state: DashboardState, panelId: number): PanelState { + if (!panelId) { + return {} as PanelState; + } + + return state.panels[panelId] ?? ({} as PanelState); +} diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts new file mode 100644 index 0000000..9b2746f --- /dev/null +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -0,0 +1,208 @@ +import { PanelMenuItem } from '@grafana/data'; +import { DashboardModel, PanelModel } from '../state'; +import { getPanelMenu } from './getPanelMenu'; +import { describe } from '../../../../test/lib/common'; +import { setStore } from 'app/store/store'; +import config from 'app/core/config'; +import * as actions from 'app/features/explore/state/main'; + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + hasAccessToExplore: () => true, + }, +})); + +describe('getPanelMenu', () => { + it('should return the correct panel menu items', () => { + const panel = new PanelModel({}); + const dashboard = new DashboardModel({}); + + const menuItems = getPanelMenu(dashboard, panel); + expect(menuItems).toMatchInlineSnapshot(` + Array [ + Object { + "iconClassName": "eye", + "onClick": [Function], + "shortcut": "v", + "text": "View", + }, + Object { + "iconClassName": "edit", + "onClick": [Function], + "shortcut": "e", + "text": "Edit", + }, + Object { + "iconClassName": "share-alt", + "onClick": [Function], + "shortcut": "p s", + "text": "Share", + }, + Object { + "iconClassName": "compass", + "onClick": [Function], + "shortcut": "x", + "text": "Explore", + }, + Object { + "iconClassName": "info-circle", + "onClick": [Function], + "shortcut": "i", + "subMenu": Array [ + Object { + "onClick": [Function], + "text": "Panel JSON", + }, + ], + "text": "Inspect", + "type": "submenu", + }, + Object { + "iconClassName": "cube", + "onClick": [Function], + "subMenu": Array [ + Object { + "onClick": [Function], + "shortcut": "p d", + "text": "Duplicate", + }, + Object { + "onClick": [Function], + "text": "Copy", + }, + Object { + "onClick": [Function], + "text": "Create library panel", + }, + ], + "text": "More...", + "type": "submenu", + }, + Object { + "text": "", + "type": "divider", + }, + Object { + "iconClassName": "trash-alt", + "onClick": [Function], + "shortcut": "p r", + "text": "Remove", + }, + ] + `); + }); + + describe('when panel is in view mode', () => { + it('should return the correct panel menu items', () => { + const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }]; + const ctrl: any = { getExtendedMenu }; + const scope: any = { $$childHead: { ctrl } }; + const angularComponent: any = { getScope: () => scope }; + const panel = new PanelModel({ isViewing: true }); + const dashboard = new DashboardModel({}); + + const menuItems = getPanelMenu(dashboard, panel, angularComponent); + expect(menuItems).toMatchInlineSnapshot(` + Array [ + Object { + "iconClassName": "eye", + "onClick": [Function], + "shortcut": "v", + "text": "View", + }, + Object { + "iconClassName": "edit", + "onClick": [Function], + "shortcut": "e", + "text": "Edit", + }, + Object { + "iconClassName": "share-alt", + "onClick": [Function], + "shortcut": "p s", + "text": "Share", + }, + Object { + "iconClassName": "compass", + "onClick": [Function], + "shortcut": "x", + "text": "Explore", + }, + Object { + "iconClassName": "info-circle", + "onClick": [Function], + "shortcut": "i", + "subMenu": Array [ + Object { + "onClick": [Function], + "text": "Panel JSON", + }, + ], + "text": "Inspect", + "type": "submenu", + }, + Object { + "iconClassName": "cube", + "onClick": [Function], + "subMenu": Array [ + Object { + "href": undefined, + "onClick": [Function], + "shortcut": "p l", + "text": "Toggle legend", + }, + ], + "text": "More...", + "type": "submenu", + }, + ] + `); + }); + }); + + describe('onNavigateToExplore', () => { + const testSubUrl = '/testSubUrl'; + const testUrl = '/testUrl'; + const windowOpen = jest.fn(); + let event: any; + let explore: PanelMenuItem; + let navigateSpy: any; + + beforeAll(() => { + const panel = new PanelModel({}); + const dashboard = new DashboardModel({}); + const menuItems = getPanelMenu(dashboard, panel); + explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; + navigateSpy = jest.spyOn(actions, 'navigateToExplore'); + window.open = windowOpen; + + event = { + ctrlKey: true, + preventDefault: jest.fn(), + }; + + setStore({ dispatch: jest.fn() } as any); + }); + + it('should navigate to url without subUrl', () => { + explore.onClick!(event); + + const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + + openInNewWindow(testUrl); + + expect(windowOpen).toHaveBeenLastCalledWith(testUrl); + }); + + it('should navigate to url with subUrl', () => { + config.appSubUrl = testSubUrl; + explore.onClick!(event); + + const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + + openInNewWindow(testUrl); + + expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); + }); + }); +}); diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts new file mode 100644 index 0000000..9dc7b2f --- /dev/null +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -0,0 +1,228 @@ +import { store } from 'app/store/store'; +import { AngularComponent, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { PanelMenuItem } from '@grafana/data'; +import { + addLibraryPanel, + copyPanel, + duplicatePanel, + removePanel, + sharePanel, + unlinkLibraryPanel, +} from 'app/features/dashboard/utils/panel'; +import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { contextSrv } from '../../../core/services/context_srv'; +import { navigateToExplore } from '../../explore/state/main'; +import { getExploreUrl } from '../../../core/utils/explore'; +import { getTimeSrv } from '../services/TimeSrv'; +import { PanelCtrl } from '../../panel/panel_ctrl'; +import config from 'app/core/config'; + +export function getPanelMenu( + dashboard: DashboardModel, + panel: PanelModel, + angularComponent?: AngularComponent | null +): PanelMenuItem[] { + const onViewPanel = (event: React.MouseEvent) => { + event.preventDefault(); + locationService.partial({ + viewPanel: panel.id, + }); + }; + + const onEditPanel = (event: React.MouseEvent) => { + event.preventDefault(); + locationService.partial({ + editPanel: panel.id, + }); + }; + + const onSharePanel = (event: React.MouseEvent) => { + event.preventDefault(); + sharePanel(dashboard, panel); + }; + + const onAddLibraryPanel = (event: React.MouseEvent) => { + event.preventDefault(); + addLibraryPanel(dashboard, panel); + }; + + const onUnlinkLibraryPanel = (event: React.MouseEvent) => { + event.preventDefault(); + unlinkLibraryPanel(panel); + }; + + const onInspectPanel = (tab?: string) => { + locationService.partial({ + inspect: panel.id, + inspectTab: tab, + }); + }; + + const onMore = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + const onDuplicatePanel = (event: React.MouseEvent) => { + event.preventDefault(); + duplicatePanel(dashboard, panel); + }; + + const onCopyPanel = (event: React.MouseEvent) => { + event.preventDefault(); + copyPanel(panel); + }; + + const onRemovePanel = (event: React.MouseEvent) => { + event.preventDefault(); + removePanel(dashboard, panel, true); + }; + + const onNavigateToExplore = (event: React.MouseEvent) => { + event.preventDefault(); + const openInNewWindow = + event.ctrlKey || event.metaKey ? (url: string) => window.open(`${config.appSubUrl}${url}`) : undefined; + store.dispatch(navigateToExplore(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow }) as any); + }; + + const menu: PanelMenuItem[] = []; + + if (!panel.isEditing) { + menu.push({ + text: 'View', + iconClassName: 'eye', + onClick: onViewPanel, + shortcut: 'v', + }); + } + + if (dashboard.canEditPanel(panel) && !panel.isEditing) { + menu.push({ + text: 'Edit', + iconClassName: 'edit', + onClick: onEditPanel, + shortcut: 'e', + }); + } + + menu.push({ + text: 'Share', + iconClassName: 'share-alt', + onClick: onSharePanel, + shortcut: 'p s', + }); + + if (contextSrv.hasAccessToExplore() && !(panel.plugin && panel.plugin.meta.skipDataQuery)) { + menu.push({ + text: 'Explore', + iconClassName: 'compass', + shortcut: 'x', + onClick: onNavigateToExplore, + }); + } + + const inspectMenu: PanelMenuItem[] = []; + + // Only show these inspect actions for data plugins + if (panel.plugin && !panel.plugin.meta.skipDataQuery) { + inspectMenu.push({ + text: 'Data', + onClick: (e: React.MouseEvent) => onInspectPanel('data'), + }); + + if (dashboard.meta.canEdit) { + inspectMenu.push({ + text: 'Query', + onClick: (e: React.MouseEvent) => onInspectPanel('query'), + }); + } + } + + inspectMenu.push({ + text: 'Panel JSON', + onClick: (e: React.MouseEvent) => onInspectPanel('json'), + }); + + menu.push({ + type: 'submenu', + text: 'Inspect', + iconClassName: 'info-circle', + onClick: (e: React.MouseEvent) => onInspectPanel(), + shortcut: 'i', + subMenu: inspectMenu, + }); + + const subMenu: PanelMenuItem[] = []; + + if (dashboard.canEditPanel(panel) && !(panel.isViewing || panel.isEditing)) { + subMenu.push({ + text: 'Duplicate', + onClick: onDuplicatePanel, + shortcut: 'p d', + }); + + subMenu.push({ + text: 'Copy', + onClick: onCopyPanel, + }); + + if (isPanelModelLibraryPanel(panel)) { + subMenu.push({ + text: 'Unlink library panel', + onClick: onUnlinkLibraryPanel, + }); + } else { + subMenu.push({ + text: 'Create library panel', + onClick: onAddLibraryPanel, + }); + } + } + + // add old angular panel options + if (angularComponent) { + const scope = angularComponent.getScope(); + const panelCtrl: PanelCtrl = scope.$$childHead.ctrl; + const angularMenuItems = panelCtrl.getExtendedMenu(); + + for (const item of angularMenuItems) { + const reactItem: PanelMenuItem = { + text: item.text, + href: item.href, + shortcut: item.shortcut, + }; + + if (item.click) { + reactItem.onClick = () => { + scope.$eval(item.click, { ctrl: panelCtrl }); + }; + } + + subMenu.push(reactItem); + } + } + + if (!panel.isEditing && subMenu.length) { + menu.push({ + type: 'submenu', + text: 'More...', + iconClassName: 'cube', + subMenu, + onClick: onMore, + }); + } + + if (dashboard.canEditPanel(panel) && !panel.isEditing && !panel.isViewing) { + menu.push({ type: 'divider', text: '' }); + + menu.push({ + text: 'Remove', + iconClassName: 'trash-alt', + onClick: onRemovePanel, + shortcut: 'p r', + }); + } + + return menu; +} diff --git a/public/app/features/dashboard/utils/getRefreshFromUrl.test.ts b/public/app/features/dashboard/utils/getRefreshFromUrl.test.ts new file mode 100644 index 0000000..c63ab1b --- /dev/null +++ b/public/app/features/dashboard/utils/getRefreshFromUrl.test.ts @@ -0,0 +1,75 @@ +import { getRefreshFromUrl } from './getRefreshFromUrl'; + +describe('getRefreshFromUrl', () => { + describe('when refresh is not part of params', () => { + it('then it should return current refresh value', () => { + const params = {}; + const currentRefresh = false; + const minRefreshInterval = '5s'; + const isAllowedIntervalFn = () => false; + + const actual = getRefreshFromUrl({ + params, + currentRefresh, + minRefreshInterval, + isAllowedIntervalFn, + }); + + expect(actual).toBe(false); + }); + }); + + describe('when refresh is part of params', () => { + describe('and refresh is an existing and valid interval', () => { + it('then it should return the refresh value', () => { + const params = { refresh: '10s' }; + const currentRefresh = ''; + const minRefreshInterval = '5s'; + const isAllowedIntervalFn = () => true; + const refreshIntervals = ['5s', '10s', '30s']; + + const actual = getRefreshFromUrl({ + params, + currentRefresh, + minRefreshInterval, + isAllowedIntervalFn, + refreshIntervals, + }); + + expect(actual).toBe('10s'); + }); + }); + + it.each` + refresh | isAllowedInterval | minRefreshInterval | refreshIntervals | expected + ${'6s'} | ${true} | ${'1s'} | ${['5s', '6s', '10s', '30s']} | ${'6s'} + ${'6s'} | ${true} | ${'10s'} | ${['5s', '10s', '30s']} | ${'10s'} + ${'6s'} | ${true} | ${'1s'} | ${['5s', '10s', '30s']} | ${'5s'} + ${'6s'} | ${true} | ${'1s'} | ${undefined} | ${'5s'} + ${'6s'} | ${true} | ${'10s'} | ${undefined} | ${'10s'} + ${'6s'} | ${true} | ${'1s'} | ${[]} | ${'currentRefresh'} + ${'6s'} | ${true} | ${'10s'} | ${[]} | ${'currentRefresh'} + ${'6s'} | ${false} | ${'1s'} | ${['5s', '6s', '10s', '30s']} | ${'5s'} + ${'6s'} | ${false} | ${'10s'} | ${['5s', '6s', '10s', '30s']} | ${'10s'} + ${'6s'} | ${false} | ${'1s'} | ${['5s', '10s', '30s']} | ${'5s'} + ${'6s'} | ${false} | ${'10s'} | ${['5s', '10s', '30s']} | ${'10s'} + ${'6s'} | ${false} | ${'1s'} | ${undefined} | ${'5s'} + ${'6s'} | ${false} | ${'10s'} | ${undefined} | ${'10s'} + ${'6s'} | ${false} | ${'1s'} | ${[]} | ${'currentRefresh'} + ${'6s'} | ${false} | ${'10s'} | ${[]} | ${'currentRefresh'} + `( + 'when called with refresh:{$refresh}, isAllowedInterval:{$isAllowedInterval}, minRefreshInterval:{$minRefreshInterval}, refreshIntervals:{$refreshIntervals} then it should return: $expected', + ({ refresh, isAllowedInterval, minRefreshInterval, refreshIntervals, expected }) => { + const actual = getRefreshFromUrl({ + params: { refresh }, + currentRefresh: 'currentRefresh', + minRefreshInterval, + isAllowedIntervalFn: () => isAllowedInterval, + refreshIntervals, + }); + + expect(actual).toBe(expected); + } + ); + }); +}); diff --git a/public/app/features/dashboard/utils/getRefreshFromUrl.ts b/public/app/features/dashboard/utils/getRefreshFromUrl.ts new file mode 100644 index 0000000..a838313 --- /dev/null +++ b/public/app/features/dashboard/utils/getRefreshFromUrl.ts @@ -0,0 +1,39 @@ +import { defaultIntervals } from '@grafana/ui'; + +interface Args { + params: Record; + currentRefresh: string | boolean | undefined; + isAllowedIntervalFn: (interval: string) => boolean; + minRefreshInterval: string; + refreshIntervals?: string[]; +} + +// getRefreshFromUrl function returns the value from the supplied &refresh= param in url. +// If the supplied interval is not allowed or does not exist in the refresh intervals for the dashboard then we +// try to find the first refresh interval that matches the minRefreshInterval (min_refresh_interval in ini) +// or just take the first interval. +export function getRefreshFromUrl({ + params, + currentRefresh, + isAllowedIntervalFn, + minRefreshInterval, + refreshIntervals = defaultIntervals, +}: Args): string | boolean | undefined { + if (!params.refresh) { + return currentRefresh; + } + + const isAllowedInterval = isAllowedIntervalFn(params.refresh); + const isExistingInterval = refreshIntervals.find((interval) => interval === params.refresh); + + if (!isAllowedInterval || !isExistingInterval) { + const minRefreshIntervalInIntervals = minRefreshInterval + ? refreshIntervals.find((interval) => interval === minRefreshInterval) + : undefined; + const lowestRefreshInterval = refreshIntervals?.length ? refreshIntervals[0] : undefined; + + return minRefreshIntervalInIntervals ?? lowestRefreshInterval ?? currentRefresh; + } + + return params.refresh || currentRefresh; +} diff --git a/public/app/features/dashboard/utils/loadSnapshotData.ts b/public/app/features/dashboard/utils/loadSnapshotData.ts new file mode 100644 index 0000000..8353915 --- /dev/null +++ b/public/app/features/dashboard/utils/loadSnapshotData.ts @@ -0,0 +1,30 @@ +import { applyFieldOverrides, ArrayDataFrame, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; +import { config } from 'app/core/config'; +import { DashboardModel, PanelModel } from '../state'; +import { getProcessedDataFrames } from '../../query/state/runRequest'; +import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker'; + +export function loadSnapshotData(panel: PanelModel, dashboard: DashboardModel): PanelData { + const data = getProcessedDataFrames(panel.snapshotData); + const worker = new SnapshotWorker(); + const options = { dashboard, range: getDefaultTimeRange() }; + const annotationEvents = worker.canWork(options) ? worker.getAnnotationsInSnapshot(dashboard, panel.id) : []; + const annotations = [new ArrayDataFrame(annotationEvents)]; + + return { + timeRange: getDefaultTimeRange(), + state: LoadingState.Done, + series: applyFieldOverrides({ + data, + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: panel.replaceVariables, + fieldConfigRegistry: panel.plugin!.fieldConfigRegistry, + theme: config.theme2, + timeZone: dashboard.getTimezone(), + }), + annotations, + }; +} diff --git a/public/app/features/dashboard/utils/panel.test.ts b/public/app/features/dashboard/utils/panel.test.ts new file mode 100644 index 0000000..15461a7 --- /dev/null +++ b/public/app/features/dashboard/utils/panel.test.ts @@ -0,0 +1,93 @@ +import { dateTime, DateTime, PanelProps, TimeRange } from '@grafana/data'; +import { applyPanelTimeOverrides, calculateInnerPanelHeight } from 'app/features/dashboard/utils/panel'; +import { advanceTo, clear } from 'jest-date-mock'; +import { PanelModel } from '../state'; +import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { ComponentClass } from 'react'; + +const dashboardTimeRange: TimeRange = { + from: dateTime([2019, 1, 11, 12, 0]), + to: dateTime([2019, 1, 11, 18, 0]), + raw: { + from: 'now-6h', + to: 'now', + }, +}; + +describe('applyPanelTimeOverrides', () => { + const fakeCurrentDate = dateTime([2019, 1, 11, 14, 0, 0]).toDate(); + + beforeAll(() => { + advanceTo(fakeCurrentDate); + }); + + afterAll(() => { + clear(); + }); + + it('should apply relative time override', () => { + const panelModel = { + timeFrom: '2h', + }; + + // @ts-ignore: PanelModel type inconsistency + const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange); + + expect(overrides.timeRange.from.toISOString()).toBe(dateTime([2019, 1, 11, 12]).toISOString()); + expect(overrides.timeRange.to.toISOString()).toBe(fakeCurrentDate.toISOString()); + expect(overrides.timeRange.raw.from).toBe('now-2h'); + expect(overrides.timeRange.raw.to).toBe('now'); + }); + + it('should apply time shift', () => { + const panelModel = { + timeShift: '2h', + }; + + const expectedFromDate = dateTime([2019, 1, 11, 10, 0, 0]).toDate(); + const expectedToDate = dateTime([2019, 1, 11, 16, 0, 0]).toDate(); + + // @ts-ignore: PanelModel type inconsistency + const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange); + + expect(overrides.timeRange.from.toISOString()).toBe(expectedFromDate.toISOString()); + expect(overrides.timeRange.to.toISOString()).toBe(expectedToDate.toISOString()); + expect((overrides.timeRange.raw.from as DateTime).toISOString()).toEqual(expectedFromDate.toISOString()); + expect((overrides.timeRange.raw.to as DateTime).toISOString()).toEqual(expectedToDate.toISOString()); + }); + + it('should apply both relative time and time shift', () => { + const panelModel = { + timeFrom: '2h', + timeShift: '2h', + }; + + const expectedFromDate = dateTime([2019, 1, 11, 10, 0, 0]).toDate(); + const expectedToDate = dateTime([2019, 1, 11, 12, 0, 0]).toDate(); + + // @ts-ignore: PanelModel type inconsistency + const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange); + + expect(overrides.timeRange.from.toISOString()).toBe(expectedFromDate.toISOString()); + expect(overrides.timeRange.to.toISOString()).toBe(expectedToDate.toISOString()); + expect((overrides.timeRange.raw.from as DateTime).toISOString()).toEqual(expectedFromDate.toISOString()); + expect((overrides.timeRange.raw.to as DateTime).toISOString()).toEqual(expectedToDate.toISOString()); + }); + + it('Calculate panel height', () => { + const panelModel = new PanelModel({}); + const height = calculateInnerPanelHeight(panelModel, 100); + + expect(height).toBe(82); + }); + + it('Calculate panel height with panel plugin zeroChromePadding', () => { + const panelModel = new PanelModel({}); + panelModel.pluginLoaded( + getPanelPlugin({ id: 'table' }, (null as unknown) as ComponentClass, null).setNoPadding() + ); + + const height = calculateInnerPanelHeight(panelModel, 100); + expect(height).toBe(98); + }); +}); diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts new file mode 100644 index 0000000..a434c03 --- /dev/null +++ b/public/app/features/dashboard/utils/panel.ts @@ -0,0 +1,189 @@ +// Store +import store from 'app/core/store'; + +// Models +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { TimeRange, AppEvents, rangeUtil, dateMath } from '@grafana/data'; + +// Utils +import { isString as _isString } from 'lodash'; +import appEvents from 'app/core/app_events'; +import config from 'app/core/config'; + +// Services +import { getTemplateSrv } from '@grafana/runtime'; + +// Constants +import { LS_PANEL_COPY_KEY, PANEL_BORDER } from 'app/core/constants'; + +import { ShareModal } from 'app/features/dashboard/components/ShareModal'; +import { ShowConfirmModalEvent, ShowModalReactEvent } from '../../../types/events'; +import { AddLibraryPanelModal } from 'app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal'; +import { UnlinkModal } from 'app/features/library-panels/components/UnlinkModal/UnlinkModal'; + +export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => { + // confirm deletion + if (ask !== false) { + const text2 = panel.alert + ? 'Panel includes an alert rule. removing the panel will also remove the alert rule' + : undefined; + const confirmText = panel.alert ? 'YES' : undefined; + + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Remove panel', + text: 'Are you sure you want to remove this panel?', + text2: text2, + icon: 'trash-alt', + confirmText: confirmText, + yesText: 'Remove', + onConfirm: () => removePanel(dashboard, panel, false), + }) + ); + return; + } + + dashboard.removePanel(panel); +}; + +export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) => { + dashboard.duplicatePanel(panel); +}; + +export const copyPanel = (panel: PanelModel) => { + let saveModel = panel; + if (panel instanceof PanelModel) { + saveModel = panel.getSaveModel(); + } + + store.set(LS_PANEL_COPY_KEY, JSON.stringify(saveModel)); + appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Click **Add panel** icon to paste.']); +}; + +export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => { + appEvents.publish( + new ShowModalReactEvent({ + component: ShareModal, + props: { + dashboard: dashboard, + panel: panel, + }, + }) + ); +}; + +export const addLibraryPanel = (dashboard: DashboardModel, panel: PanelModel) => { + appEvents.publish( + new ShowModalReactEvent({ + component: AddLibraryPanelModal, + props: { + panel, + initialFolderId: dashboard.meta.folderId, + isOpen: true, + }, + }) + ); +}; + +export const unlinkLibraryPanel = (panel: PanelModel) => { + appEvents.publish( + new ShowModalReactEvent({ + component: UnlinkModal, + props: { + onConfirm: () => { + delete panel.libraryPanel; + panel.render(); + }, + isOpen: true, + }, + }) + ); +}; + +export const refreshPanel = (panel: PanelModel) => { + panel.refresh(); +}; + +export const toggleLegend = (panel: PanelModel) => { + console.warn('Toggle legend is not implemented yet'); + // We need to set panel.legend defaults first + // panel.legend.show = !panel.legend.show; + refreshPanel(panel); +}; + +export interface TimeOverrideResult { + timeRange: TimeRange; + timeInfo: string; +} + +export function applyPanelTimeOverrides(panel: PanelModel, timeRange: TimeRange): TimeOverrideResult { + const newTimeData = { + timeInfo: '', + timeRange: timeRange, + }; + + if (panel.timeFrom) { + const timeFromInterpolated = getTemplateSrv().replace(panel.timeFrom, panel.scopedVars); + const timeFromInfo = rangeUtil.describeTextRange(timeFromInterpolated); + if (timeFromInfo.invalid) { + newTimeData.timeInfo = 'invalid time override'; + return newTimeData; + } + + if (_isString(timeRange.raw.from)) { + const timeFromDate = dateMath.parse(timeFromInfo.from)!; + newTimeData.timeInfo = timeFromInfo.display; + newTimeData.timeRange = { + from: timeFromDate, + to: dateMath.parse(timeFromInfo.to)!, + raw: { + from: timeFromInfo.from, + to: timeFromInfo.to, + }, + }; + } + } + + if (panel.timeShift) { + const timeShiftInterpolated = getTemplateSrv().replace(panel.timeShift, panel.scopedVars); + const timeShiftInfo = rangeUtil.describeTextRange(timeShiftInterpolated); + if (timeShiftInfo.invalid) { + newTimeData.timeInfo = 'invalid timeshift'; + return newTimeData; + } + + const timeShift = '-' + timeShiftInterpolated; + newTimeData.timeInfo += ' timeshift ' + timeShift; + const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!; + const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!; + + newTimeData.timeRange = { + from, + to, + raw: { + from, + to, + }, + }; + } + + if (panel.hideTimeOverride) { + newTimeData.timeInfo = ''; + } + + return newTimeData; +} + +export function getResolution(panel: PanelModel): number { + const htmlEl = document.getElementsByTagName('html')[0]; + const width = htmlEl.getBoundingClientRect().width; // https://stackoverflow.com/a/21454625 + + return panel.maxDataPoints ? panel.maxDataPoints : Math.ceil(width * (panel.gridPos.w / 24)); +} + +export function calculateInnerPanelHeight(panel: PanelModel, containerHeight: number): number { + const chromePadding = panel.plugin && panel.plugin.noPadding ? 0 : config.theme.panelPadding * 2; + const headerHeight = panel.hasTitle() ? config.theme.panelHeaderHeight : 0; + return containerHeight - headerHeight - chromePadding - PANEL_BORDER; +} diff --git a/public/app/features/datasources/DashboardsTable.test.tsx b/public/app/features/datasources/DashboardsTable.test.tsx new file mode 100644 index 0000000..10a14f4 --- /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 0000000..da71c9d --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.tsx @@ -0,0 +1,54 @@ +import React, { FC } from 'react'; +import { PluginDashboard } from '../../types'; +import { Button, Icon } from '@grafana/ui'; + +export interface Props { + dashboards: PluginDashboard[]; + onImport: (dashboard: PluginDashboard, overwrite: boolean) => void; + onRemove: (dashboard: PluginDashboard) => void; +} + +const DashboardsTable: FC = ({ 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 0000000..b901335 --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceDashboards, Props } from './DataSourceDashboards'; +import { DataSourceSettings, NavModel } from '@grafana/data'; +import { PluginDashboard } from 'app/types'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; + +const setup = (propOverrides?: object) => { + const props: Props = { + ...getRouteComponentProps(), + navModel: {} as NavModel, + dashboards: [] as PluginDashboard[], + dataSource: {} as DataSourceSettings, + dataSourceId: 'x', + importDashboard: jest.fn(), + loadDataSource: jest.fn(), + loadPluginDashboards: jest.fn(), + removeDashboard: jest.fn(), + isLoading: false, + }; + + 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 0000000..738b663 --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -0,0 +1,93 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +// Components +import Page from 'app/core/components/Page/Page'; +import DashboardTable from './DashboardsTable'; + +// Actions & Selectors +import { getNavModel } from 'app/core/selectors/navModel'; +import { loadDataSource } from './state/actions'; +import { loadPluginDashboards } from '../plugins/state/actions'; +import { importDashboard, removeDashboard } from '../dashboard/state/actions'; +import { getDataSource } from './state/selectors'; + +// Types +import { PluginDashboard, StoreState } from 'app/types'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} + +function mapStateToProps(state: StoreState, props: OwnProps) { + const dataSourceId = props.match.params.uid; + + return { + navModel: getNavModel(state.navIndex, `datasource-dashboards-${dataSourceId}`), + dashboards: state.plugins.dashboards, + dataSource: getDataSource(state.dataSources, dataSourceId), + isLoading: state.plugins.isLoadingPluginDashboards, + dataSourceId, + }; +} + +const mapDispatchToProps = { + importDashboard, + loadDataSource, + loadPluginDashboards, + removeDashboard, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export type Props = OwnProps & ConnectedProps; + +export class DataSourceDashboards extends PureComponent { + async componentDidMount() { + const { loadDataSource, dataSourceId } = this.props; + await loadDataSource(dataSourceId); + this.props.loadPluginDashboards(); + } + + onImport = (dashboard: PluginDashboard, overwrite: boolean) => { + const { dataSource, importDashboard } = this.props; + const data: any = { + pluginId: dashboard.pluginId, + path: dashboard.path, + 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, isLoading } = this.props; + return ( + + + this.onImport(dashboard, overwrite)} + onRemove={(dashboard) => this.onRemove(dashboard)} + /> + + + ); + } +} + +export default connector(DataSourceDashboards); diff --git a/public/app/features/datasources/DataSourceList.test.tsx b/public/app/features/datasources/DataSourceList.test.tsx new file mode 100644 index 0000000..c6752e9 --- /dev/null +++ b/public/app/features/datasources/DataSourceList.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DataSourcesList from './DataSourcesList'; +import { getMockDataSources } from './__mocks__/dataSourcesMocks'; +import { LayoutModes } from '@grafana/data'; + +const setup = () => { + const props = { + dataSources: getMockDataSources(3), + layoutMode: LayoutModes.Grid, + }; + + return render(); +}; + +describe('DataSourcesList', () => { + it('should render list of datasources', () => { + setup(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getAllByRole('heading')).toHaveLength(3); + }); + + it('should render all elements in the list item', () => { + setup(); + expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'dataSource-0 dataSource-0' })).toBeInTheDocument(); + expect(screen.getByAltText('dataSource-0')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx new file mode 100644 index 0000000..faee371 --- /dev/null +++ b/public/app/features/datasources/DataSourcesList.tsx @@ -0,0 +1,49 @@ +// Libraries +import React, { FC } from 'react'; + +// Types +import { DataSourceSettings, LayoutMode } from '@grafana/data'; +import { Card, Tag, useStyles } from '@grafana/ui'; +import { css } from '@emotion/css'; + +export interface Props { + dataSources: DataSourceSettings[]; + layoutMode: LayoutMode; +} + +export const DataSourcesList: FC = ({ dataSources, layoutMode }) => { + const styles = useStyles(getStyles); + + return ( +
      + {dataSources.map((dataSource, index) => { + return ( +
    • + + + {dataSource.name} + + + {[ + dataSource.typeName, + dataSource.url, + dataSource.isDefault && , + ]} + + +
    • + ); + })} +
    + ); +}; + +export default DataSourcesList; + +const getStyles = () => { + return { + list: css` + list-style: none; + `, + }; +}; diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx new file mode 100644 index 0000000..bff292d --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceSettings, NavModel, LayoutModes } from '@grafana/data'; + +import { DataSourcesListPage, Props } from './DataSourcesListPage'; +import { getMockDataSources } from './__mocks__/dataSourcesMocks'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dataSources: [] as DataSourceSettings[], + layoutMode: LayoutModes.Grid, + loadDataSources: jest.fn(), + navModel: { + main: { + text: 'Configuration', + }, + node: { + text: 'Data Sources', + }, + } as NavModel, + dataSourcesCount: 0, + searchQuery: '', + setDataSourcesSearchQuery, + setDataSourcesLayoutMode, + hasFetched: false, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render action bar and datasources', () => { + const wrapper = setup({ + dataSources: getMockDataSources(5), + dataSourcesCount: 5, + hasFetched: true, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx new file mode 100644 index 0000000..77ccb06 --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -0,0 +1,109 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +// Components +import Page from 'app/core/components/Page/Page'; +import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import DataSourcesList from './DataSourcesList'; +// Types +import { DataSourceSettings, NavModel, LayoutMode } from '@grafana/data'; +import { IconName } from '@grafana/ui'; +import { StoreState } from 'app/types'; +// Actions +import { loadDataSources } from './state/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; + +import { + getDataSources, + getDataSourcesCount, + getDataSourcesLayoutMode, + getDataSourcesSearchQuery, +} from './state/selectors'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; + +export interface Props { + navModel: NavModel; + dataSources: DataSourceSettings[]; + dataSourcesCount: number; + layoutMode: LayoutMode; + searchQuery: string; + hasFetched: boolean; + loadDataSources: typeof loadDataSources; + setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode; + setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery; +} + +const emptyListModel = { + title: 'No data sources defined', + buttonIcon: 'database' as IconName, + buttonLink: 'datasources/new', + buttonTitle: 'Add data source', + proTip: 'You can also define data sources through configuration files.', + proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list', + proTipLinkTitle: 'Learn more', + proTipTarget: '_blank', +}; + +export class DataSourcesListPage extends PureComponent { + componentDidMount() { + this.props.loadDataSources(); + } + + render() { + const { + dataSources, + dataSourcesCount, + navModel, + layoutMode, + searchQuery, + setDataSourcesSearchQuery, + hasFetched, + } = this.props; + + const linkButton = { + href: 'datasources/new', + title: 'Add data source', + }; + + return ( + + + <> + {hasFetched && dataSourcesCount === 0 && } + {hasFetched && + dataSourcesCount > 0 && [ + setDataSourcesSearchQuery(query)} + linkButton={linkButton} + key="action-bar" + />, + , + ]} + + + + ); + } +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'datasources'), + dataSources: getDataSources(state.dataSources), + layoutMode: getDataSourcesLayoutMode(state.dataSources), + dataSourcesCount: getDataSourcesCount(state.dataSources), + searchQuery: getDataSourcesSearchQuery(state.dataSources), + hasFetched: state.dataSources.hasFetched, + }; +} + +const mapDispatchToProps = { + loadDataSources, + setDataSourcesSearchQuery, + setDataSourcesLayoutMode, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage)); diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx new file mode 100644 index 0000000..946c7dc --- /dev/null +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -0,0 +1,199 @@ +import React, { FC, PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import { DataSourcePluginMeta, NavModel } from '@grafana/data'; +import { Button, LinkButton, List, PluginSignatureBadge } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +import Page from 'app/core/components/Page/Page'; +import { DataSourcePluginCategory, StoreState } from 'app/types'; +import { addDataSource, loadDataSourcePlugins } from './state/actions'; +import { getDataSourcePlugins } from './state/selectors'; +import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { setDataSourceTypeSearchQuery } from './state/reducers'; +import { Card } from 'app/core/components/Card/Card'; +import { PluginsErrorsInfo } from '../plugins/PluginsErrorsInfo'; + +export interface Props { + navModel: NavModel; + plugins: DataSourcePluginMeta[]; + categories: DataSourcePluginCategory[]; + isLoading: boolean; + addDataSource: typeof addDataSource; + loadDataSourcePlugins: typeof loadDataSourcePlugins; + searchQuery: string; + setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery; +} + +class NewDataSourcePage extends PureComponent { + componentDidMount() { + this.props.loadDataSourcePlugins(); + } + + onDataSourceTypeClicked = (plugin: DataSourcePluginMeta) => { + this.props.addDataSource(plugin); + }; + + onSearchQueryChange = (value: string) => { + this.props.setDataSourceTypeSearchQuery(value); + }; + + renderPlugins(plugins: DataSourcePluginMeta[]) { + if (!plugins || !plugins.length) { + return null; + } + + return ( + item.id.toString()} + renderItem={(item) => ( + this.onDataSourceTypeClicked(item)} + onLearnMoreClick={this.onLearnMoreClick} + /> + )} + /> + ); + } + + onLearnMoreClick = (evt: React.SyntheticEvent) => { + evt.stopPropagation(); + }; + + renderCategories() { + const { categories } = this.props; + + return ( + <> + {categories.map((category) => ( +
    +
    {category.title}
    + {this.renderPlugins(category.plugins)} +
    + ))} +
    + + Find more data source plugins on grafana.com + +
    + + ); + } + + render() { + const { navModel, isLoading, searchQuery, plugins } = this.props; + + return ( + + +
    + +
    + + Cancel + +
    + {!searchQuery && ( + + <> +
    +

    + Note that unsigned front-end data source plugins are still usable, but this is subject to change in + the upcoming releases of Grafana. +

    + +
    + )} +
    + {searchQuery && this.renderPlugins(plugins)} + {!searchQuery && this.renderCategories()} +
    + + + ); + } +} + +interface DataSourceTypeCardProps { + plugin: DataSourcePluginMeta; + onClick: () => void; + onLearnMoreClick: (evt: React.SyntheticEvent) => void; +} + +const DataSourceTypeCard: FC = (props) => { + const { plugin, onLearnMoreClick } = props; + const isPhantom = plugin.module === 'phantom'; + const onClick = !isPhantom && !plugin.unlicensed ? props.onClick : () => {}; + // find first plugin info link + const learnMoreLink = plugin.info?.links?.length > 0 ? plugin.info.links[0] : null; + + return ( + + {learnMoreLink && ( + + {learnMoreLink.name} + + )} + {!isPhantom && } + + } + labels={!isPhantom && } + className={isPhantom ? 'add-data-source-item--phantom' : ''} + onClick={onClick} + aria-label={selectors.pages.AddDataSource.dataSourcePlugins(plugin.name)} + /> + ); +}; + +export function getNavModel(): NavModel { + const main = { + icon: 'database', + id: 'datasource-new', + text: 'Add data source', + href: 'datasources/new', + subTitle: 'Choose a data source type', + }; + + return { + main: main, + node: main, + }; +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(), + plugins: getDataSourcePlugins(state.dataSources), + searchQuery: state.dataSources.dataSourceTypeSearchQuery, + categories: state.dataSources.categories, + isLoading: state.dataSources.isLoadingDataSources, + }; +} + +const mapDispatchToProps = { + addDataSource, + loadDataSourcePlugins, + setDataSourceTypeSearchQuery, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(NewDataSourcePage)); diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts new file mode 100644 index 0000000..26a3bfe --- /dev/null +++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts @@ -0,0 +1,51 @@ +import { DataSourceSettings } from '@grafana/data'; + +export const getMockDataSources = (amount: number) => { + const dataSources = []; + + for (let i = 0; i < amount; i++) { + dataSources.push({ + access: '', + basicAuth: false, + database: `database-${i}`, + id: i, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: `dataSource-${i}`, + orgId: 1, + password: '', + readOnly: false, + type: 'cloudwatch', + typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', + url: '', + user: '', + }); + } + + return dataSources as DataSourceSettings[]; +}; + +export const getMockDataSource = (): DataSourceSettings => { + return { + access: '', + basicAuth: false, + basicAuthUser: '', + basicAuthPassword: '', + withCredentials: false, + database: '', + id: 13, + uid: 'x', + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'gdev-cloudwatch', + typeName: 'Cloudwatch', + orgId: 1, + password: '', + readOnly: false, + type: 'cloudwatch', + typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', + url: '', + user: '', + secureJsonFields: {}, + }; +}; 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 0000000..b0b6486 --- /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 0000000..28b20de --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + + + + +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap new file mode 100644 index 0000000..91ad79a --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap @@ -0,0 +1,154 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render action bar and datasources 1`] = ` + + + + + + +`; + +exports[`Render should render component 1`] = ` + + + +`; diff --git a/public/app/features/datasources/mocks.ts b/public/app/features/datasources/mocks.ts new file mode 100644 index 0000000..f03e350 --- /dev/null +++ b/public/app/features/datasources/mocks.ts @@ -0,0 +1,26 @@ +import { DataSourceSettings } from '@grafana/data'; + +export function createDatasourceSettings(jsonData: T): DataSourceSettings { + return { + id: 0, + uid: 'x', + orgId: 0, + name: 'datasource-test', + typeLogoUrl: '', + type: 'datasource', + typeName: 'Datasource', + access: 'server', + url: 'http://localhost', + password: '', + user: '', + database: '', + basicAuth: false, + basicAuthPassword: '', + basicAuthUser: '', + isDefault: false, + jsonData, + readOnly: false, + withCredentials: false, + secureJsonFields: {}, + }; +} diff --git a/public/app/features/datasources/partials/http_settings.html b/public/app/features/datasources/partials/http_settings.html new file mode 100644 index 0000000..e69de29 diff --git a/public/app/features/datasources/partials/http_settings_next.html b/public/app/features/datasources/partials/http_settings_next.html new file mode 100644 index 0000000..57df2b5 --- /dev/null +++ b/public/app/features/datasources/partials/http_settings_next.html @@ -0,0 +1 @@ + diff --git a/public/app/features/datasources/partials/tls_auth_settings.html b/public/app/features/datasources/partials/tls_auth_settings.html new file mode 100644 index 0000000..f15193b --- /dev/null +++ b/public/app/features/datasources/partials/tls_auth_settings.html @@ -0,0 +1,62 @@ +
    +
    +
    TLS/SSL Auth Details
    + TLS/SSL certificates are encrypted and stored in the Grafana database. +
    +
    +
    +
    +
    + +
    + +
    + + reset +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + + reset +
    +
    + +
    +
    +
    + +
    +
    + + reset +
    +
    +
    +
    diff --git a/public/app/features/datasources/settings/BasicSettings.test.tsx b/public/app/features/datasources/settings/BasicSettings.test.tsx new file mode 100644 index 0000000..0194adc --- /dev/null +++ b/public/app/features/datasources/settings/BasicSettings.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import BasicSettings, { Props } from './BasicSettings'; + +const setup = () => { + const props: Props = { + dataSourceName: 'Graphite', + isDefault: false, + onDefaultChange: jest.fn(), + onNameChange: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/BasicSettings.tsx b/public/app/features/datasources/settings/BasicSettings.tsx new file mode 100644 index 0000000..1271672 --- /dev/null +++ b/public/app/features/datasources/settings/BasicSettings.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { InlineFormLabel, LegacyForms } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +const { Input, Switch } = LegacyForms; + +export interface Props { + dataSourceName: string; + isDefault: boolean; + onNameChange: (name: string) => void; + onDefaultChange: (value: boolean) => void; +} + +const BasicSettings: FC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => { + return ( +
    +
    +
    + + Name + + onNameChange(event.target.value)} + required + aria-label={selectors.pages.DataSource.name} + /> +
    + { + // @ts-ignore + onDefaultChange(event.target.checked); + }} + /> +
    +
    + ); +}; + +export default BasicSettings; diff --git a/public/app/features/datasources/settings/ButtonRow.test.tsx b/public/app/features/datasources/settings/ButtonRow.test.tsx new file mode 100644 index 0000000..84b16d8 --- /dev/null +++ b/public/app/features/datasources/settings/ButtonRow.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ButtonRow, { Props } from './ButtonRow'; + +const setup = (propOverrides?: object) => { + const props: Props = { + isReadOnly: true, + onSubmit: jest.fn(), + onDelete: jest.fn(), + onTest: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render with buttons enabled', () => { + const wrapper = setup({ + isReadOnly: false, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/settings/ButtonRow.tsx new file mode 100644 index 0000000..7631b47 --- /dev/null +++ b/public/app/features/datasources/settings/ButtonRow.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { selectors } from '@grafana/e2e-selectors'; + +import config from 'app/core/config'; +import { Button, LinkButton } from '@grafana/ui'; + +export interface Props { + isReadOnly: boolean; + onDelete: () => void; + onSubmit: (event: any) => void; + onTest: (event: any) => void; +} + +const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit, onTest }) => { + return ( +
    + + Back + + + {!isReadOnly && ( + + )} + {isReadOnly && ( + + )} +
    + ); +}; + +export default ButtonRow; diff --git a/public/app/features/datasources/settings/CloudInfoBox.tsx b/public/app/features/datasources/settings/CloudInfoBox.tsx new file mode 100644 index 0000000..96990bf --- /dev/null +++ b/public/app/features/datasources/settings/CloudInfoBox.tsx @@ -0,0 +1,73 @@ +import { DataSourceSettings } from '@grafana/data'; +import { Alert } from '@grafana/ui'; +import React, { FC } from 'react'; +import { config } from 'app/core/config'; +import { GrafanaEdition } from '@grafana/data/src/types/config'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +const LOCAL_STORAGE_KEY = 'datasources.settings.cloudInfoBox.isDismissed'; + +export interface Props { + dataSource: DataSourceSettings; +} + +export const CloudInfoBox: FC = ({ dataSource }) => { + let mainDS = ''; + let extraDS = ''; + + // don't show for already configured data sources or provisioned data sources + if (dataSource.readOnly || (dataSource.version ?? 0) > 2) { + return null; + } + + // Skip showing this info box in some editions + if (config.buildInfo.edition !== GrafanaEdition.OpenSource) { + return null; + } + + switch (dataSource.type) { + case 'prometheus': + mainDS = 'Prometheus'; + extraDS = 'Loki'; + break; + case 'loki': + mainDS = 'Loki'; + extraDS = 'Prometheus'; + break; + default: + return null; + } + + return ( + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + return ( + { + onDismiss(true); + }} + > + Or skip the effort and get {mainDS} (and {extraDS}) as fully-managed, scalable, and hosted data sources from + Grafana Labs with the{' '} + + free-forever Grafana Cloud plan + + . + + ); + }} + + ); +}; diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx new file mode 100644 index 0000000..460c151 --- /dev/null +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage'; +import { getMockDataSource } from '../__mocks__/dataSourcesMocks'; +import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { cleanUpAction } from 'app/core/actions/cleanUp'; +import { screen, render } from '@testing-library/react'; +import { selectors } from '@grafana/e2e-selectors'; +import { PluginState } from '@grafana/data'; + +const getMockNode = () => ({ + text: 'text', + subTitle: 'subtitle', + icon: 'icon', +}); + +const getProps = (): Props => ({ + ...getRouteComponentProps(), + navModel: { + node: getMockNode(), + main: getMockNode(), + }, + dataSource: getMockDataSource(), + dataSourceMeta: getMockPlugin(), + dataSourceId: 'x', + deleteDataSource: jest.fn(), + loadDataSource: jest.fn(), + setDataSourceName, + updateDataSource: jest.fn(), + initDataSourceSettings: jest.fn(), + testDataSource: jest.fn(), + setIsDefault, + dataSourceLoaded, + cleanUpAction, + page: null, + plugin: null, + loadError: null, + testingStatus: {}, +}); + +describe('Render', () => { + it('should not render loading when props are ready', () => { + render(); + + expect(screen.queryByText('Loading ...')).not.toBeInTheDocument(); + }); + + it('should render loading if datasource is not ready', () => { + const mockProps = getProps(); + mockProps.dataSource.id = 0; + + render(); + + expect(screen.getByText('Loading ...')).toBeInTheDocument(); + }); + + it('should render beta info text if plugin state is beta', () => { + const mockProps = getProps(); + mockProps.dataSourceMeta.state = PluginState.beta; + + render(); + + expect( + screen.getByTitle('Beta Plugin: There could be bugs and minor breaking changes to this plugin') + ).toBeInTheDocument(); + }); + + it('should render alpha info text if plugin state is alpha', () => { + const mockProps = getProps(); + mockProps.dataSourceMeta.state = PluginState.alpha; + + render(); + + expect( + screen.getByTitle('Alpha Plugin: This plugin is a work in progress and updates may include breaking changes') + ).toBeInTheDocument(); + }); + + it('should not render is ready only message is readOnly is false', () => { + const mockProps = getProps(); + mockProps.dataSource.readOnly = false; + + render(); + + expect(screen.queryByLabelText(selectors.pages.DataSource.readOnly)).not.toBeInTheDocument(); + }); + + it('should render is ready only message is readOnly is true', () => { + const mockProps = getProps(); + mockProps.dataSource.readOnly = true; + + render(); + + expect(screen.getByLabelText(selectors.pages.DataSource.readOnly)).toBeInTheDocument(); + }); + + it('should render error message with detailed message', () => { + const mockProps = { + ...getProps(), + testingStatus: { + message: 'message', + status: 'error', + details: { message: 'detailed message' }, + }, + }; + + render(); + + expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument(); + expect(screen.getByText(mockProps.testingStatus.details.message)).toBeInTheDocument(); + }); + + it('should render error message with empty details', () => { + const mockProps = { + ...getProps(), + testingStatus: { + message: 'message', + status: 'error', + details: {}, + }, + }; + + render(); + + expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument(); + }); + + it('should render error message without details', () => { + const mockProps = { + ...getProps(), + testingStatus: { + message: 'message', + status: 'error', + }, + }; + + render(); + + expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument(); + }); + + it('should render verbose error message with detailed verbose error message', () => { + const mockProps = { + ...getProps(), + testingStatus: { + message: 'message', + status: 'error', + details: { message: 'detailed message', verboseMessage: 'verbose message' }, + }, + }; + + render(); + + expect(screen.getByText(mockProps.testingStatus.details.verboseMessage)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx new file mode 100644 index 0000000..ec219da --- /dev/null +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -0,0 +1,288 @@ +import React, { PureComponent } from 'react'; +import { isString } from 'lodash'; +// Components +import Page from 'app/core/components/Page/Page'; +import { PluginSettings } from './PluginSettings'; +import BasicSettings from './BasicSettings'; +import ButtonRow from './ButtonRow'; +// Services & Utils +import appEvents from 'app/core/app_events'; +// Actions & selectors +import { getDataSource, getDataSourceMeta } from '../state/selectors'; +import { + deleteDataSource, + initDataSourceSettings, + loadDataSource, + testDataSource, + updateDataSource, +} from '../state/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; + +// Types +import { StoreState } from 'app/types/'; +import { DataSourceSettings } from '@grafana/data'; +import { Alert, Button, LinkButton } from '@grafana/ui'; +import { getDataSourceLoadingNav } from '../state/navModel'; +import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; +import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; +import { selectors } from '@grafana/e2e-selectors'; +import { CloudInfoBox } from './CloudInfoBox'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { connect, ConnectedProps } from 'react-redux'; +import { cleanUpAction } from 'app/core/actions/cleanUp'; +import { ShowConfirmModalEvent } from '../../../types/events'; + +export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} + +function mapStateToProps(state: StoreState, props: OwnProps) { + const dataSourceId = props.match.params.uid; + const params = new URLSearchParams(props.location.search); + const dataSource = getDataSource(state.dataSources, dataSourceId); + const { plugin, loadError, testingStatus } = state.dataSourceSettings; + const page = params.get('page'); + + return { + navModel: getNavModel( + state.navIndex, + page ? `datasource-page-${page}` : `datasource-settings-${dataSourceId}`, + getDataSourceLoadingNav('settings') + ), + dataSource: getDataSource(state.dataSources, dataSourceId), + dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type), + dataSourceId: dataSourceId, + page, + plugin, + loadError, + testingStatus, + }; +} + +const mapDispatchToProps = { + deleteDataSource, + loadDataSource, + setDataSourceName, + updateDataSource, + setIsDefault, + dataSourceLoaded, + initDataSourceSettings, + testDataSource, + cleanUpAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export type Props = OwnProps & ConnectedProps; + +export class DataSourceSettingsPage extends PureComponent { + componentDidMount() { + const { initDataSourceSettings, dataSourceId } = this.props; + initDataSourceSettings(dataSourceId); + } + + componentWillUnmount() { + this.props.cleanUpAction({ + stateSelector: (state) => state.dataSourceSettings, + }); + } + + onSubmit = async (evt: React.FormEvent) => { + evt.preventDefault(); + + await this.props.updateDataSource({ ...this.props.dataSource }); + + this.testDataSource(); + }; + + onTest = async (evt: React.FormEvent) => { + evt.preventDefault(); + + this.testDataSource(); + }; + + onDelete = () => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Delete', + text: 'Are you sure you want to delete this data source?', + yesText: 'Delete', + icon: 'trash-alt', + onConfirm: () => { + this.confirmDelete(); + }, + }) + ); + }; + + confirmDelete = () => { + this.props.deleteDataSource(); + }; + + onModelChange = (dataSource: DataSourceSettings) => { + this.props.dataSourceLoaded(dataSource); + }; + + isReadOnly() { + return this.props.dataSource.readOnly === true; + } + + renderIsReadOnlyMessage() { + return ( + + This data source was added by config and cannot be modified using the UI. Please contact your server admin to + update this data source. + + ); + } + + testDataSource() { + const { dataSource, testDataSource } = this.props; + testDataSource(dataSource.name); + } + + get hasDataSource() { + return this.props.dataSource.id > 0; + } + + renderLoadError(loadError: any) { + let showDelete = false; + let msg = loadError.toString(); + if (loadError.data) { + if (loadError.data.message) { + msg = loadError.data.message; + } + } else if (isString(loadError)) { + showDelete = true; + } + + const node = { + text: msg, + subTitle: 'Data Source Error', + icon: 'exclamation-triangle', + }; + const nav = { + node: node, + main: node, + }; + + return ( + + +
    +
    + {showDelete && ( + + )} + + Back + +
    +
    +
    +
    + ); + } + + renderConfigPageBody(page: string) { + const { plugin } = this.props; + if (!plugin || !plugin.configPages) { + return null; // still loading + } + + for (const p of plugin.configPages) { + if (p.id === page) { + // Investigate is any plugins using this? We should change this interface + return ; + } + } + + return
    Page not found: {page}
    ; + } + + renderAlertDetails() { + const { testingStatus } = this.props; + + return ( + <> + {testingStatus?.details?.message} + {testingStatus?.details?.verboseMessage ? ( +
    {testingStatus?.details?.verboseMessage}
    + ) : null} + + ); + } + + renderSettings() { + const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props; + + return ( +
    + {this.isReadOnly() && this.renderIsReadOnlyMessage()} + {dataSourceMeta.state && ( +
    + + +
    + )} + + + + setIsDefault(state)} + onNameChange={(name) => setDataSourceName(name)} + /> + + {plugin && ( + + )} + + {testingStatus?.message && ( +
    + + {testingStatus.details && this.renderAlertDetails()} + +
    + )} + + this.onSubmit(event)} + isReadOnly={this.isReadOnly()} + onDelete={this.onDelete} + onTest={(event) => this.onTest(event)} + /> + + ); + } + + render() { + const { navModel, page, loadError } = this.props; + + if (loadError) { + return this.renderLoadError(loadError); + } + + return ( + + + {this.hasDataSource ?
    {page ? this.renderConfigPageBody(page) : this.renderSettings()}
    : null} +
    +
    + ); + } +} + +export default connector(DataSourceSettingsPage); diff --git a/public/app/features/datasources/settings/HttpSettingsCtrl.ts b/public/app/features/datasources/settings/HttpSettingsCtrl.ts new file mode 100644 index 0000000..59b664a --- /dev/null +++ b/public/app/features/datasources/settings/HttpSettingsCtrl.ts @@ -0,0 +1,21 @@ +import { coreModule } from 'app/core/core'; + +coreModule.directive('datasourceHttpSettings', () => { + return { + scope: { + current: '=', + suggestUrl: '@', + noDirectAccess: '@', + }, + templateUrl: 'public/app/features/datasources/partials/http_settings_next.html', + link: { + pre: ($scope: any) => { + // do not show access option if direct access is disabled + $scope.showAccessOption = $scope.noDirectAccess !== 'true'; + $scope.onChange = (datasourceSetting: any) => { + $scope.current = datasourceSetting; + }; + }, + }, + }; +}); diff --git a/public/app/features/datasources/settings/PluginSettings.tsx b/public/app/features/datasources/settings/PluginSettings.tsx new file mode 100644 index 0000000..429f3de --- /dev/null +++ b/public/app/features/datasources/settings/PluginSettings.tsx @@ -0,0 +1,95 @@ +import React, { PureComponent } from 'react'; +import { cloneDeep } from 'lodash'; +import { + DataQuery, + DataSourceApi, + DataSourceJsonData, + DataSourcePlugin, + DataSourcePluginMeta, + DataSourceSettings, +} from '@grafana/data'; +import { AngularComponent, getAngularLoader } from '@grafana/runtime'; + +export type GenericDataSourcePlugin = DataSourcePlugin>; + +export interface Props { + plugin: GenericDataSourcePlugin; + dataSource: DataSourceSettings; + dataSourceMeta: DataSourcePluginMeta; + onModelChange: (dataSource: DataSourceSettings) => void; +} + +export class PluginSettings extends PureComponent { + element: HTMLDivElement | null = null; + component?: AngularComponent; + scopeProps: { + ctrl: { datasourceMeta: DataSourcePluginMeta; current: DataSourceSettings }; + onModelChanged: (dataSource: DataSourceSettings) => void; + }; + + constructor(props: Props) { + super(props); + + this.scopeProps = { + ctrl: { datasourceMeta: props.dataSourceMeta, current: cloneDeep(props.dataSource) }, + onModelChanged: this.onModelChanged, + }; + this.onModelChanged = this.onModelChanged.bind(this); + } + + componentDidMount() { + const { plugin } = this.props; + + if (!this.element) { + return; + } + + if (!plugin.components.ConfigEditor) { + // React editor is not specified, let's render angular editor + // How to approach this better? Introduce ReactDataSourcePlugin interface and typeguard it here? + const loader = getAngularLoader(); + const template = ''; + + this.component = loader.load(this.element, this.scopeProps, template); + } + } + + componentDidUpdate(prevProps: Props) { + const { plugin } = this.props; + if (!plugin.components.ConfigEditor && this.props.dataSource !== prevProps.dataSource) { + this.scopeProps.ctrl.current = cloneDeep(this.props.dataSource); + + this.component?.digest(); + } + } + + componentWillUnmount() { + if (this.component) { + this.component.destroy(); + } + } + + onModelChanged = (dataSource: DataSourceSettings) => { + this.props.onModelChange(dataSource); + }; + + render() { + const { plugin, dataSource } = this.props; + + if (!plugin) { + return null; + } + + return ( +
    (this.element = element)}> + {plugin.components.ConfigEditor && + React.createElement(plugin.components.ConfigEditor, { + options: dataSource, + onOptionsChange: this.onModelChanged, + })} +
    + ); + } +} + +export default PluginSettings; diff --git a/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts new file mode 100644 index 0000000..7c21fab --- /dev/null +++ b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts @@ -0,0 +1,10 @@ +import { coreModule } from 'app/core/core'; + +coreModule.directive('datasourceTlsAuthSettings', () => { + return { + scope: { + current: '=', + }, + templateUrl: 'public/app/features/datasources/partials/tls_auth_settings.html', + }; +}); diff --git a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap new file mode 100644 index 0000000..a91abfb --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    +
    +
    + + Name + + +
    + +
    +
    +`; diff --git a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap new file mode 100644 index 0000000..c9e5ccb --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    + + Back + + + +
    +`; + +exports[`Render should render with buttons enabled 1`] = ` +
    + + Back + + + +
    +`; diff --git a/public/app/features/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts new file mode 100644 index 0000000..6ed49ee --- /dev/null +++ b/public/app/features/datasources/state/actions.test.ts @@ -0,0 +1,198 @@ +import { + findNewName, + nameExits, + InitDataSourceSettingDependencies, + testDataSource, + TestDataSourceDependencies, +} from './actions'; +import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; +import { thunkTester } from 'test/core/thunk/thunkTester'; +import { + initDataSourceSettingsSucceeded, + initDataSourceSettingsFailed, + testDataSourceStarting, + testDataSourceSucceeded, + testDataSourceFailed, +} from './reducers'; +import { initDataSourceSettings } from '../state/actions'; +import { ThunkResult, ThunkDispatch } from 'app/types'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +const getBackendSrvMock = () => + ({ + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn().mockReturnValue({ + status: '', + message: '', + }), + }), + withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()), + } as any); + +describe('Name exists', () => { + const plugins = getMockPlugins(5); + + it('should be true', () => { + const name = 'pretty cool plugin-1'; + + expect(nameExits(plugins, name)).toEqual(true); + }); + + it('should be false', () => { + const name = 'pretty cool plugin-6'; + + expect(nameExits(plugins, name)); + }); +}); + +describe('Find new name', () => { + it('should create a new name', () => { + const plugins = getMockPlugins(5); + const name = 'pretty cool plugin-1'; + + expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6'); + }); + + it('should create new name without suffix', () => { + const plugin = getMockPlugin(); + plugin.name = 'prometheus'; + const plugins = [plugin]; + const name = 'prometheus'; + + expect(findNewName(plugins, name)).toEqual('prometheus-1'); + }); + + it('should handle names that end with -', () => { + const plugin = getMockPlugin(); + const plugins = [plugin]; + const name = 'pretty cool plugin-'; + + expect(findNewName(plugins, name)).toEqual('pretty cool plugin-'); + }); +}); + +describe('initDataSourceSettings', () => { + describe('when pageId is missing', () => { + it('then initDataSourceSettingsFailed should be dispatched', async () => { + const dispatchedActions = await thunkTester({}).givenThunk(initDataSourceSettings).whenThunkIsDispatched(''); + + expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid ID'))]); + }); + }); + + describe('when pageId is a valid', () => { + it('then initDataSourceSettingsSucceeded should be dispatched', async () => { + const thunkMock = (): ThunkResult => (dispatch: ThunkDispatch, getState) => {}; + const dataSource = { type: 'app' }; + const dataSourceMeta = { id: 'some id' }; + const dependencies: InitDataSourceSettingDependencies = { + loadDataSource: jest.fn(thunkMock) as any, + getDataSource: jest.fn().mockReturnValue(dataSource), + getDataSourceMeta: jest.fn().mockReturnValue(dataSourceMeta), + importDataSourcePlugin: jest.fn().mockReturnValue({} as GenericDataSourcePlugin), + }; + const state = { + dataSourceSettings: {}, + dataSources: {}, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(initDataSourceSettings) + .whenThunkIsDispatched(256, dependencies); + + expect(dispatchedActions).toEqual([initDataSourceSettingsSucceeded({} as GenericDataSourcePlugin)]); + expect(dependencies.loadDataSource).toHaveBeenCalledTimes(1); + expect(dependencies.loadDataSource).toHaveBeenCalledWith(256); + + expect(dependencies.getDataSource).toHaveBeenCalledTimes(1); + expect(dependencies.getDataSource).toHaveBeenCalledWith({}, 256); + + expect(dependencies.getDataSourceMeta).toHaveBeenCalledTimes(1); + expect(dependencies.getDataSourceMeta).toHaveBeenCalledWith({}, 'app'); + + expect(dependencies.importDataSourcePlugin).toHaveBeenCalledTimes(1); + expect(dependencies.importDataSourcePlugin).toHaveBeenCalledWith(dataSourceMeta); + }); + }); + + describe('when plugin loading fails', () => { + it('then initDataSourceSettingsFailed should be dispatched', async () => { + const dependencies: InitDataSourceSettingDependencies = { + loadDataSource: jest.fn().mockImplementation(() => { + throw new Error('Error loading plugin'); + }), + getDataSource: jest.fn(), + getDataSourceMeta: jest.fn(), + importDataSourcePlugin: jest.fn(), + }; + const state = { + dataSourceSettings: {}, + dataSources: {}, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(initDataSourceSettings) + .whenThunkIsDispatched(301, dependencies); + + expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Error loading plugin'))]); + expect(dependencies.loadDataSource).toHaveBeenCalledTimes(1); + expect(dependencies.loadDataSource).toHaveBeenCalledWith(301); + }); + }); +}); + +describe('testDataSource', () => { + describe('when a datasource is tested', () => { + it('then testDataSourceStarting and testDataSourceSucceeded should be dispatched', async () => { + const dependencies: TestDataSourceDependencies = { + getDatasourceSrv: () => + ({ + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn().mockReturnValue({ + status: '', + message: '', + }), + }), + } as any), + getBackendSrv: getBackendSrvMock, + }; + const state = { + testingStatus: { + status: '', + message: '', + }, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(testDataSource) + .whenThunkIsDispatched('Azure Monitor', dependencies); + + expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceSucceeded(state.testingStatus)]); + }); + + it('then testDataSourceFailed should be dispatched', async () => { + const dependencies: TestDataSourceDependencies = { + getDatasourceSrv: () => + ({ + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn().mockImplementation(() => { + throw new Error('Error testing datasource'); + }), + }), + } as any), + getBackendSrv: getBackendSrvMock, + }; + const result = { + message: 'Error testing datasource', + }; + const state = { + testingStatus: { + message: '', + status: '', + }, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(testDataSource) + .whenThunkIsDispatched('Azure Monitor', dependencies); + + expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]); + }); + }); +}); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts new file mode 100644 index 0000000..47cd4c1 --- /dev/null +++ b/public/app/features/datasources/state/actions.ts @@ -0,0 +1,278 @@ +import config from '../../../core/config'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { updateNavIndex } from 'app/core/actions'; +import { buildNavModel } from './navModel'; +import { DataSourcePluginMeta, DataSourceSettings, locationUtil } from '@grafana/data'; +import { DataSourcePluginCategory, ThunkResult, ThunkDispatch } from 'app/types'; +import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache'; +import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader'; +import { + dataSourceLoaded, + dataSourceMetaLoaded, + dataSourcePluginsLoad, + dataSourcePluginsLoaded, + dataSourcesLoaded, + initDataSourceSettingsFailed, + initDataSourceSettingsSucceeded, + testDataSourceStarting, + testDataSourceSucceeded, + testDataSourceFailed, +} from './reducers'; +import { buildCategories } from './buildCategories'; +import { getDataSource, getDataSourceMeta } from './selectors'; +import { getDataSourceSrv, locationService } from '@grafana/runtime'; + +export interface DataSourceTypesLoadedPayload { + plugins: DataSourcePluginMeta[]; + categories: DataSourcePluginCategory[]; +} + +export interface InitDataSourceSettingDependencies { + loadDataSource: typeof loadDataSource; + getDataSource: typeof getDataSource; + getDataSourceMeta: typeof getDataSourceMeta; + importDataSourcePlugin: typeof importDataSourcePlugin; +} + +export interface TestDataSourceDependencies { + getDatasourceSrv: typeof getDataSourceSrv; + getBackendSrv: typeof getBackendSrv; +} + +export const initDataSourceSettings = ( + pageId: string, + dependencies: InitDataSourceSettingDependencies = { + loadDataSource, + getDataSource, + getDataSourceMeta, + importDataSourcePlugin, + } +): ThunkResult => { + return async (dispatch, getState) => { + if (!pageId) { + dispatch(initDataSourceSettingsFailed(new Error('Invalid ID'))); + return; + } + + try { + await dispatch(dependencies.loadDataSource(pageId)); + + // have we already loaded the plugin then we can skip the steps below? + if (getState().dataSourceSettings.plugin) { + return; + } + + const dataSource = dependencies.getDataSource(getState().dataSources, pageId); + const dataSourceMeta = dependencies.getDataSourceMeta(getState().dataSources, dataSource!.type); + const importedPlugin = await dependencies.importDataSourcePlugin(dataSourceMeta); + + dispatch(initDataSourceSettingsSucceeded(importedPlugin)); + } catch (err) { + console.error('Failed to import plugin module', err); + dispatch(initDataSourceSettingsFailed(err)); + } + }; +}; + +export const testDataSource = ( + dataSourceName: string, + dependencies: TestDataSourceDependencies = { + getDatasourceSrv, + getBackendSrv, + } +): ThunkResult => { + return async (dispatch: ThunkDispatch, getState) => { + const dsApi = await dependencies.getDatasourceSrv().get(dataSourceName); + + if (!dsApi.testDatasource) { + return; + } + + dispatch(testDataSourceStarting()); + + dependencies.getBackendSrv().withNoBackendCache(async () => { + try { + const result = await dsApi.testDatasource(); + + dispatch(testDataSourceSucceeded(result)); + } catch (err) { + const { statusText, message: errMessage, details } = err; + const message = statusText ? 'HTTP error ' + statusText : errMessage; + + dispatch(testDataSourceFailed({ message, details })); + } + }); + }; +}; + +export function loadDataSources(): ThunkResult { + return async (dispatch) => { + const response = await getBackendSrv().get('/api/datasources'); + dispatch(dataSourcesLoaded(response)); + }; +} + +export function loadDataSource(uid: string): ThunkResult { + return async (dispatch) => { + const dataSource = await getDataSourceUsingUidOrId(uid); + const pluginInfo = (await getPluginSettings(dataSource.type)) as DataSourcePluginMeta; + const plugin = await importDataSourcePlugin(pluginInfo); + + dispatch(dataSourceLoaded(dataSource)); + dispatch(dataSourceMetaLoaded(pluginInfo)); + dispatch(updateNavIndex(buildNavModel(dataSource, plugin))); + }; +} + +/** + * Get data source by uid or id, if old id detected handles redirect + */ +async function getDataSourceUsingUidOrId(uid: string): Promise { + // Try first with uid api + try { + const byUid = await getBackendSrv() + .fetch({ + method: 'GET', + url: `/api/datasources/uid/${uid}`, + showErrorAlert: false, + }) + .toPromise(); + + if (byUid.ok) { + return byUid.data; + } + } catch (err) { + console.log('Failed to lookup data source by uid', err); + } + + // try lookup by old db id + const id = parseInt(uid, 10); + if (!Number.isNaN(id)) { + const response = await getBackendSrv() + .fetch({ + method: 'GET', + url: `/api/datasources/${id}`, + showErrorAlert: false, + }) + .toPromise(); + + // Not ideal to do a full page reload here but so tricky to handle this otherwise + // We can update the location using react router, but need to fully reload the route as the nav model + // page index is not matching with the url in that case. And react router has no way to unmount remount a route + if (response.ok && response.data.id.toString() === uid) { + window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${response.data.uid}`); + return {} as DataSourceSettings; // avoids flashing an error + } + } + + throw Error('Could not find data source'); +} + +export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult { + return async (dispatch, getStore) => { + await dispatch(loadDataSources()); + + const dataSources = getStore().dataSources.dataSources; + + const newInstance = { + name: plugin.name, + type: plugin.id, + access: 'proxy', + isDefault: dataSources.length === 0, + }; + + if (nameExits(dataSources, newInstance.name)) { + newInstance.name = findNewName(dataSources, newInstance.name); + } + + const result = await getBackendSrv().post('/api/datasources', newInstance); + locationService.push(`/datasources/edit/${result.datasource.uid}`); + }; +} + +export function loadDataSourcePlugins(): ThunkResult { + return async (dispatch) => { + dispatch(dataSourcePluginsLoad()); + const plugins = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' }); + const categories = buildCategories(plugins); + dispatch(dataSourcePluginsLoaded({ plugins, categories })); + }; +} + +export function updateDataSource(dataSource: DataSourceSettings): ThunkResult { + return async (dispatch) => { + await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource); // by UID not yet supported + await updateFrontendSettings(); + return dispatch(loadDataSource(dataSource.uid)); + }; +} + +export function deleteDataSource(): ThunkResult { + return async (dispatch, getStore) => { + const dataSource = getStore().dataSources.dataSource; + + await getBackendSrv().delete(`/api/datasources/${dataSource.id}`); + await updateFrontendSettings(); + + locationService.push('/datasources'); + }; +} + +interface ItemWithName { + name: string; +} + +export function nameExits(dataSources: ItemWithName[], name: string) { + return ( + dataSources.filter((dataSource) => { + return dataSource.name.toLowerCase() === name.toLowerCase(); + }).length > 0 + ); +} + +export function findNewName(dataSources: ItemWithName[], name: string) { + // Need to loop through current data sources to make sure + // the name doesn't exist + while (nameExits(dataSources, name)) { + // If there's a duplicate name that doesn't end with '-x' + // we can add -1 to the name and be done. + if (!nameHasSuffix(name)) { + name = `${name}-1`; + } else { + // if there's a duplicate name that ends with '-x' + // we can try to increment the last digit until the name is unique + + // remove the 'x' part and replace it with the new number + name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`; + } + } + + return name; +} + +function updateFrontendSettings() { + return getBackendSrv() + .get('/api/frontend/settings') + .then((settings: any) => { + config.datasources = settings.datasources; + config.defaultDatasource = settings.defaultDatasource; + getDatasourceSrv().init(config.datasources, settings.defaultDatasource); + }); +} + +function nameHasSuffix(name: string) { + return name.endsWith('-', name.length - 1); +} + +function getLastDigit(name: string) { + return parseInt(name.slice(-1), 10); +} + +function incrementLastDigit(digit: number) { + return isNaN(digit) ? 1 : digit + 1; +} + +function getNewName(name: string) { + return name.slice(0, name.length - 1); +} diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts new file mode 100644 index 0000000..f2c8fcf --- /dev/null +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -0,0 +1,56 @@ +import { buildCategories } from './buildCategories'; +import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { DataSourcePluginMeta } from '@grafana/data'; + +const plugins: DataSourcePluginMeta[] = [ + { + ...getMockPlugin({ id: 'graphite' }), + category: 'tsdb', + }, + { + ...getMockPlugin({ id: 'prometheus' }), + category: 'tsdb', + }, + { + ...getMockPlugin({ id: 'elasticsearch' }), + category: 'logging', + }, + { + ...getMockPlugin({ id: 'loki' }), + category: 'logging', + }, + { + ...getMockPlugin({ id: 'azure' }), + category: 'cloud', + }, +]; + +describe('buildCategories', () => { + const categories = buildCategories(plugins); + + it('should group plugins into categories and remove empty categories', () => { + expect(categories.length).toBe(4); + expect(categories[0].title).toBe('Time series databases'); + expect(categories[0].plugins.length).toBe(2); + expect(categories[1].title).toBe('Logging & document databases'); + }); + + it('should sort plugins according to hard coded sorting rules', () => { + expect(categories[1].plugins[0].id).toBe('loki'); + }); + + it('should add phantom plugin for Grafana cloud', () => { + expect(categories[2].title).toBe('Cloud'); + expect(categories[2].plugins.length).toBe(2); + expect(categories[2].plugins[1].id).toBe('gcloud'); + }); + + it('should set module to phantom on phantom plugins', () => { + expect(categories[3].plugins[0].module).toBe('phantom'); + }); + + it('should add enterprise phantom plugins', () => { + expect(categories[3].title).toBe('Enterprise plugins'); + expect(categories[3].plugins.length).toBe(10); + }); +}); diff --git a/public/app/features/datasources/state/buildCategories.ts b/public/app/features/datasources/state/buildCategories.ts new file mode 100644 index 0000000..3a26ec8 --- /dev/null +++ b/public/app/features/datasources/state/buildCategories.ts @@ -0,0 +1,215 @@ +import { DataSourcePluginMeta, PluginType } from '@grafana/data'; +import { DataSourcePluginCategory } from 'app/types'; +import { config } from '../../../core/config'; + +export function buildCategories(plugins: DataSourcePluginMeta[]): DataSourcePluginCategory[] { + const categories: DataSourcePluginCategory[] = [ + { id: 'tsdb', title: 'Time series databases', plugins: [] }, + { id: 'logging', title: 'Logging & document databases', plugins: [] }, + { id: 'tracing', title: 'Distributed tracing', plugins: [] }, + { id: 'sql', title: 'SQL', plugins: [] }, + { id: 'cloud', title: 'Cloud', plugins: [] }, + { id: 'enterprise', title: 'Enterprise plugins', plugins: [] }, + { id: 'iot', title: 'Industrial & IoT', plugins: [] }, + { id: 'other', title: 'Others', plugins: [] }, + ].filter((item) => item); + + const categoryIndex: Record = {}; + const pluginIndex: Record = {}; + const enterprisePlugins = getEnterprisePhantomPlugins(); + + // build indices + for (const category of categories) { + categoryIndex[category.id] = category; + } + + const { edition, hasValidLicense } = config.licenseInfo; + + for (const plugin of plugins) { + const enterprisePlugin = enterprisePlugins.find((item) => item.id === plugin.id); + // Force category for enterprise plugins + if (plugin.enterprise || enterprisePlugin) { + plugin.category = 'enterprise'; + plugin.unlicensed = edition !== 'Open Source' && !hasValidLicense; + plugin.info.links = enterprisePlugin?.info?.links || plugin.info.links; + } + + // Fix link name + if (plugin.info.links) { + for (const link of plugin.info.links) { + link.name = 'Learn more'; + } + } + + const category = categories.find((item) => item.id === plugin.category) || categoryIndex['other']; + category.plugins.push(plugin); + // add to plugin index + pluginIndex[plugin.id] = plugin; + } + + for (const category of categories) { + // add phantom plugin + if (category.id === 'cloud') { + category.plugins.push(getGrafanaCloudPhantomPlugin()); + } + + // add phantom plugins + if (category.id === 'enterprise') { + for (const plugin of enterprisePlugins) { + if (!pluginIndex[plugin.id]) { + category.plugins.push(plugin); + } + } + } + + sortPlugins(category.plugins); + } + + // Only show categories with plugins + return categories.filter((c) => c.plugins.length > 0); +} + +function sortPlugins(plugins: DataSourcePluginMeta[]) { + const sortingRules: { [id: string]: number } = { + prometheus: 100, + graphite: 95, + loki: 90, + mysql: 80, + jaeger: 100, + postgres: 79, + gcloud: -1, + }; + + plugins.sort((a, b) => { + const aSort = sortingRules[a.id] || 0; + const bSort = sortingRules[b.id] || 0; + if (aSort > bSort) { + return -1; + } + if (aSort < bSort) { + return 1; + } + + return a.name > b.name ? -1 : 1; + }); +} + +function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] { + return [ + getPhantomPlugin({ + id: 'grafana-splunk-datasource', + name: 'Splunk', + description: 'Visualize and explore Splunk logs', + imgUrl: 'public/img/plugins/splunk_logo_128.png', + }), + getPhantomPlugin({ + id: 'grafana-oracle-datasource', + name: 'Oracle', + description: 'Visualize and explore Oracle SQL', + imgUrl: 'public/img/plugins/oracle.png', + }), + getPhantomPlugin({ + id: 'grafana-dynatrace-datasource', + name: 'Dynatrace', + description: 'Visualize and explore Dynatrace data', + imgUrl: 'public/img/plugins/dynatrace.png', + }), + getPhantomPlugin({ + id: 'grafana-servicenow-datasource', + description: 'ServiceNow integration and data source', + name: 'ServiceNow', + imgUrl: 'public/img/plugins/servicenow.svg', + }), + getPhantomPlugin({ + id: 'grafana-datadog-datasource', + description: 'DataDog integration and data source', + name: 'DataDog', + imgUrl: 'public/img/plugins/datadog.png', + }), + getPhantomPlugin({ + id: 'grafana-newrelic-datasource', + description: 'New Relic integration and data source', + name: 'New Relic', + imgUrl: 'public/img/plugins/newrelic.svg', + }), + getPhantomPlugin({ + id: 'grafana-mongodb-datasource', + description: 'MongoDB integration and data source', + name: 'MongoDB', + imgUrl: 'public/img/plugins/mongodb.svg', + }), + getPhantomPlugin({ + id: 'grafana-snowflake-datasource', + description: 'Snowflake integration and data source', + name: 'Snowflake', + imgUrl: 'public/img/plugins/snowflake.svg', + }), + getPhantomPlugin({ + id: 'grafana-wavefront-datasource', + description: 'Wavefront integration and data source', + name: 'Wavefront', + imgUrl: 'public/img/plugins/wavefront.svg', + }), + getPhantomPlugin({ + id: 'dlopes7-appdynamics-datasource', + description: 'AppDynamics integration and data source', + name: 'AppDynamics', + imgUrl: 'public/img/plugins/appdynamics.svg', + }), + ]; +} + +function getGrafanaCloudPhantomPlugin(): DataSourcePluginMeta { + return { + id: 'gcloud', + name: 'Grafana Cloud', + type: PluginType.datasource, + module: 'phantom', + baseUrl: '', + info: { + description: 'Hosted Graphite, Prometheus, and Loki', + logos: { small: 'public/img/grafana_icon.svg', large: 'asd' }, + author: { name: 'Grafana Labs' }, + links: [ + { + url: 'https://grafana.com/products/cloud/', + name: 'Learn more', + }, + ], + screenshots: [], + updated: '2019-05-10', + version: '1.0.0', + }, + }; +} + +interface GetPhantomPluginOptions { + id: string; + name: string; + description: string; + imgUrl: string; +} + +function getPhantomPlugin(options: GetPhantomPluginOptions): DataSourcePluginMeta { + return { + id: options.id, + name: options.name, + type: PluginType.datasource, + module: 'phantom', + baseUrl: '', + info: { + description: options.description, + logos: { small: options.imgUrl, large: options.imgUrl }, + author: { name: 'Grafana Labs' }, + links: [ + { + url: config.marketplaceUrl + options.id, + name: 'Install now', + }, + ], + screenshots: [], + updated: '2019-05-10', + version: '1.0.0', + }, + }; +} diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts new file mode 100644 index 0000000..dcded6d --- /dev/null +++ b/public/app/features/datasources/state/navModel.ts @@ -0,0 +1,151 @@ +import { DataSourceSettings, PluginType, PluginInclude, NavModel, NavModelItem } from '@grafana/data'; +import config from 'app/core/config'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDataSourcePlugin): NavModelItem { + const pluginMeta = plugin.meta; + + const navModel: NavModelItem = { + img: pluginMeta.info.logos.large, + id: 'datasource-' + dataSource.uid, + subTitle: `Type: ${pluginMeta.name}`, + url: '', + text: dataSource.name, + breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }], + children: [ + { + active: false, + icon: 'sliders-v-alt', + id: `datasource-settings-${dataSource.uid}`, + text: 'Settings', + url: `datasources/edit/${dataSource.uid}/`, + }, + ], + }; + + if (plugin.configPages) { + for (const page of plugin.configPages) { + navModel.children!.push({ + active: false, + text: page.title, + icon: page.icon, + url: `datasources/edit/${dataSource.uid}/?page=${page.id}`, + id: `datasource-page-${page.id}`, + }); + } + } + + if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) { + navModel.children!.push({ + active: false, + icon: 'apps', + id: `datasource-dashboards-${dataSource.uid}`, + text: 'Dashboards', + url: `datasources/edit/${dataSource.uid}/dashboards`, + }); + } + + if (config.licenseInfo.hasLicense) { + navModel.children!.push({ + active: false, + icon: 'lock', + id: `datasource-permissions-${dataSource.id}`, + text: 'Permissions', + url: `datasources/edit/${dataSource.id}/permissions`, + }); + + navModel.children!.push({ + active: false, + icon: 'info-circle', + id: `datasource-insights-${dataSource.id}`, + text: 'Insights', + url: `datasources/edit/${dataSource.id}/insights`, + }); + + navModel.children!.push({ + active: false, + icon: 'database', + id: `datasource-cache-${dataSource.id}`, + text: 'Cache', + url: `datasources/edit/${dataSource.id}/cache`, + }); + } + + return navModel; +} + +export function getDataSourceLoadingNav(pageName: string): NavModel { + const main = buildNavModel( + { + access: '', + basicAuth: false, + basicAuthUser: '', + basicAuthPassword: '', + withCredentials: false, + database: '', + id: 1, + uid: 'x', + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'Loading', + orgId: 1, + password: '', + readOnly: false, + type: 'Loading', + typeName: 'Loading', + typeLogoUrl: 'public/img/icn-datasource.svg', + url: '', + user: '', + secureJsonFields: {}, + }, + { + meta: { + id: '1', + type: PluginType.datasource, + name: '', + info: { + author: { + name: '', + url: '', + }, + description: '', + links: [{ name: '', url: '' }], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + includes: [], + module: '', + baseUrl: '', + }, + } as any + ); + + 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: PluginInclude[]): boolean { + return ( + includes.find((include) => { + return include.type === 'dashboard'; + }) !== undefined + ); +} diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts new file mode 100644 index 0000000..8cc844d --- /dev/null +++ b/public/app/features/datasources/state/reducers.test.ts @@ -0,0 +1,173 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { + dataSourceLoaded, + dataSourceMetaLoaded, + dataSourcePluginsLoad, + dataSourcePluginsLoaded, + dataSourceSettingsReducer, + dataSourcesLoaded, + dataSourcesReducer, + initDataSourceSettingsFailed, + initDataSourceSettingsSucceeded, + initialDataSourceSettingsState, + initialState, + setDataSourceName, + setDataSourcesLayoutMode, + setDataSourcesSearchQuery, + setDataSourceTypeSearchQuery, + setIsDefault, +} from './reducers'; +import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks'; +import { DataSourceSettingsState, DataSourcesState } from 'app/types'; +import { PluginMeta, PluginMetaInfo, PluginType, LayoutModes } from '@grafana/data'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +const mockPlugin = () => + ({ + defaultNavUrl: 'defaultNavUrl', + enabled: true, + hasUpdate: true, + id: 'id', + info: {} as PluginMetaInfo, + latestVersion: 'latestVersion', + name: 'name', + pinned: true, + type: PluginType.datasource, + module: 'path/to/module', + } as PluginMeta); + +describe('dataSourcesReducer', () => { + describe('when dataSourcesLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSources = getMockDataSources(1); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourcesLoaded(dataSources)) + .thenStateShouldEqual({ ...initialState, hasFetched: true, dataSources, dataSourcesCount: 1 }); + }); + }); + + describe('when dataSourceLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSource = getMockDataSource(); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourceLoaded(dataSource)) + .thenStateShouldEqual({ ...initialState, dataSource }); + }); + }); + + describe('when setDataSourcesSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourcesSearchQuery('some query')) + .thenStateShouldEqual({ ...initialState, searchQuery: 'some query' }); + }); + }); + + describe('when setDataSourcesLayoutMode is dispatched', () => { + it('then state should be correct', () => { + const layoutMode: LayoutModes = LayoutModes.Grid; + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourcesLayoutMode(layoutMode)) + .thenStateShouldEqual({ ...initialState, layoutMode: LayoutModes.Grid }); + }); + }); + + describe('when dataSourcePluginsLoad is dispatched', () => { + it('then state should be correct', () => { + const state: DataSourcesState = { ...initialState, plugins: [mockPlugin()] }; + + reducerTester() + .givenReducer(dataSourcesReducer, state) + .whenActionIsDispatched(dataSourcePluginsLoad()) + .thenStateShouldEqual({ ...initialState, isLoadingDataSources: true }); + }); + }); + + describe('when dataSourcePluginsLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSourceTypes = [mockPlugin()]; + const state: DataSourcesState = { ...initialState, isLoadingDataSources: true }; + + reducerTester() + .givenReducer(dataSourcesReducer, state) + .whenActionIsDispatched(dataSourcePluginsLoaded({ plugins: dataSourceTypes, categories: [] })) + .thenStateShouldEqual({ ...initialState, plugins: dataSourceTypes, isLoadingDataSources: false }); + }); + }); + + describe('when setDataSourceTypeSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourceTypeSearchQuery('type search query')) + .thenStateShouldEqual({ ...initialState, dataSourceTypeSearchQuery: 'type search query' }); + }); + }); + + describe('when dataSourceMetaLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSourceMeta = mockPlugin(); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourceMetaLoaded(dataSourceMeta)) + .thenStateShouldEqual({ ...initialState, dataSourceMeta }); + }); + }); + + describe('when setDataSourceName is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourceName('some name')) + .thenStateShouldEqual({ ...initialState, dataSource: { name: 'some name' } } as DataSourcesState); + }); + }); + + describe('when setIsDefault is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setIsDefault(true)) + .thenStateShouldEqual({ ...initialState, dataSource: { isDefault: true } } as DataSourcesState); + }); + }); +}); + +describe('dataSourceSettingsReducer', () => { + describe('when initDataSourceSettingsSucceeded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourceSettingsReducer, { ...initialDataSourceSettingsState }) + .whenActionIsDispatched(initDataSourceSettingsSucceeded({} as GenericDataSourcePlugin)) + .thenStateShouldEqual({ + ...initialDataSourceSettingsState, + plugin: {} as GenericDataSourcePlugin, + loadError: null, + }); + }); + }); + + describe('when initDataSourceSettingsFailed is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourceSettingsReducer, { + ...initialDataSourceSettingsState, + plugin: {} as GenericDataSourcePlugin, + }) + .whenActionIsDispatched(initDataSourceSettingsFailed(new Error('Some error'))) + .thenStatePredicateShouldEqual((resultingState) => { + expect(resultingState.plugin).toEqual(null); + expect(resultingState.loadError).toEqual('Some error'); + return true; + }); + }); + }); +}); diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts new file mode 100644 index 0000000..44fe799 --- /dev/null +++ b/public/app/features/datasources/state/reducers.ts @@ -0,0 +1,165 @@ +import { AnyAction, createAction } from '@reduxjs/toolkit'; +import { DataSourcePluginMeta, DataSourceSettings, LayoutMode, LayoutModes } from '@grafana/data'; + +import { DataSourcesState, DataSourceSettingsState, TestingStatus } from 'app/types'; +import { DataSourceTypesLoadedPayload } from './actions'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +export const initialState: DataSourcesState = { + dataSources: [], + plugins: [], + categories: [], + dataSource: {} as DataSourceSettings, + layoutMode: LayoutModes.List, + searchQuery: '', + dataSourcesCount: 0, + dataSourceTypeSearchQuery: '', + hasFetched: false, + isLoadingDataSources: false, + dataSourceMeta: {} as DataSourcePluginMeta, +}; + +export const dataSourceLoaded = createAction('dataSources/dataSourceLoaded'); +export const dataSourcesLoaded = createAction('dataSources/dataSourcesLoaded'); +export const dataSourceMetaLoaded = createAction('dataSources/dataSourceMetaLoaded'); +export const dataSourcePluginsLoad = createAction('dataSources/dataSourcePluginsLoad'); +export const dataSourcePluginsLoaded = createAction( + 'dataSources/dataSourcePluginsLoaded' +); +export const setDataSourcesSearchQuery = createAction('dataSources/setDataSourcesSearchQuery'); +export const setDataSourcesLayoutMode = createAction('dataSources/setDataSourcesLayoutMode'); +export const setDataSourceTypeSearchQuery = createAction('dataSources/setDataSourceTypeSearchQuery'); +export const setDataSourceName = createAction('dataSources/setDataSourceName'); +export const setIsDefault = createAction('dataSources/setIsDefault'); + +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because Angular would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const dataSourcesReducer = (state: DataSourcesState = initialState, action: AnyAction): DataSourcesState => { + if (dataSourcesLoaded.match(action)) { + return { + ...state, + hasFetched: true, + dataSources: action.payload, + dataSourcesCount: action.payload.length, + }; + } + + if (dataSourceLoaded.match(action)) { + return { ...state, dataSource: action.payload }; + } + + if (setDataSourcesSearchQuery.match(action)) { + return { ...state, searchQuery: action.payload }; + } + + if (setDataSourcesLayoutMode.match(action)) { + return { ...state, layoutMode: action.payload }; + } + + if (dataSourcePluginsLoad.match(action)) { + return { ...state, plugins: [], isLoadingDataSources: true }; + } + + if (dataSourcePluginsLoaded.match(action)) { + return { + ...state, + plugins: action.payload.plugins, + categories: action.payload.categories, + isLoadingDataSources: false, + }; + } + + if (setDataSourceTypeSearchQuery.match(action)) { + return { ...state, dataSourceTypeSearchQuery: action.payload }; + } + + if (dataSourceMetaLoaded.match(action)) { + return { ...state, dataSourceMeta: action.payload }; + } + + if (setDataSourceName.match(action)) { + return { ...state, dataSource: { ...state.dataSource, name: action.payload } }; + } + + if (setIsDefault.match(action)) { + return { + ...state, + dataSource: { ...state.dataSource, isDefault: action.payload }, + }; + } + + return state; +}; + +export const initialDataSourceSettingsState: DataSourceSettingsState = { + testingStatus: {}, + loadError: null, + plugin: null, +}; + +export const initDataSourceSettingsSucceeded = createAction( + 'dataSourceSettings/initDataSourceSettingsSucceeded' +); + +export const initDataSourceSettingsFailed = createAction('dataSourceSettings/initDataSourceSettingsFailed'); + +export const testDataSourceStarting = createAction('dataSourceSettings/testDataSourceStarting'); + +export const testDataSourceSucceeded = createAction('dataSourceSettings/testDataSourceSucceeded'); + +export const testDataSourceFailed = createAction('dataSourceSettings/testDataSourceFailed'); + +export const dataSourceSettingsReducer = ( + state: DataSourceSettingsState = initialDataSourceSettingsState, + action: AnyAction +): DataSourceSettingsState => { + if (initDataSourceSettingsSucceeded.match(action)) { + return { ...state, plugin: action.payload, loadError: null }; + } + + if (initDataSourceSettingsFailed.match(action)) { + return { ...state, plugin: null, loadError: action.payload.message }; + } + + if (testDataSourceStarting.match(action)) { + return { + ...state, + testingStatus: { + message: 'Testing...', + status: 'info', + }, + }; + } + + if (testDataSourceSucceeded.match(action)) { + return { + ...state, + testingStatus: { + status: action.payload?.status, + message: action.payload?.message, + details: action.payload?.details, + }, + }; + } + + if (testDataSourceFailed.match(action)) { + return { + ...state, + testingStatus: { + status: 'error', + message: action.payload?.message, + details: action.payload?.details, + }, + }; + } + + return state; +}; + +export default { + dataSources: dataSourcesReducer, + dataSourceSettings: dataSourceSettingsReducer, +}; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts new file mode 100644 index 0000000..b09e796 --- /dev/null +++ b/public/app/features/datasources/state/selectors.ts @@ -0,0 +1,37 @@ +import { DataSourcePluginMeta, DataSourceSettings, UrlQueryValue } from '@grafana/data'; +import { DataSourcesState } from '../../../types/datasources'; + +export const getDataSources = (state: DataSourcesState) => { + const regex = new RegExp(state.searchQuery, 'i'); + + return state.dataSources.filter((dataSource: DataSourceSettings) => { + return regex.test(dataSource.name) || regex.test(dataSource.database) || regex.test(dataSource.type); + }); +}; + +export const getDataSourcePlugins = (state: DataSourcesState) => { + const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i'); + + return state.plugins.filter((type: DataSourcePluginMeta) => { + return regex.test(type.name); + }); +}; + +export const getDataSource = (state: DataSourcesState, dataSourceId: UrlQueryValue): DataSourceSettings => { + if (state.dataSource.uid === dataSourceId) { + return state.dataSource; + } + return {} as DataSourceSettings; +}; + +export const getDataSourceMeta = (state: DataSourcesState, type: string): DataSourcePluginMeta => { + if (state.dataSourceMeta.id === type) { + return state.dataSourceMeta; + } + + return {} as DataSourcePluginMeta; +}; + +export const getDataSourcesSearchQuery = (state: DataSourcesState) => state.searchQuery; +export const getDataSourcesLayoutMode = (state: DataSourcesState) => state.layoutMode; +export const getDataSourcesCount = (state: DataSourcesState) => state.dataSourcesCount; diff --git a/public/app/features/datasources/utils/passwordHandlers.test.ts b/public/app/features/datasources/utils/passwordHandlers.test.ts new file mode 100644 index 0000000..bb55b95 --- /dev/null +++ b/public/app/features/datasources/utils/passwordHandlers.test.ts @@ -0,0 +1,32 @@ +import { createResetHandler, PasswordFieldEnum, Ctrl } from './passwordHandlers'; +describe('createResetHandler', () => { + Object.values(PasswordFieldEnum).forEach((field) => { + it(`should reset existing ${field} field`, () => { + const event: any = { + preventDefault: () => {}, + }; + const ctrl: Ctrl = { + current: { + [field]: 'set', + secureJsonData: { + [field]: 'set', + }, + secureJsonFields: {}, + }, + }; + + createResetHandler(ctrl, field)(event); + expect(ctrl).toEqual({ + current: { + [field]: undefined, + secureJsonData: { + [field]: '', + }, + secureJsonFields: { + [field]: false, + }, + }, + }); + }); + }); +}); diff --git a/public/app/features/datasources/utils/passwordHandlers.ts b/public/app/features/datasources/utils/passwordHandlers.ts new file mode 100644 index 0000000..63db5f8 --- /dev/null +++ b/public/app/features/datasources/utils/passwordHandlers.ts @@ -0,0 +1,45 @@ +/** + * Set of handlers for secure password field in Angular components. They handle backward compatibility with + * passwords stored in plain text fields. + */ + +import { SyntheticEvent } from 'react'; + +export enum PasswordFieldEnum { + Password = 'password', + BasicAuthPassword = 'basicAuthPassword', +} + +/** + * Basic shape for settings controllers in at the moment mostly angular data source plugins. + */ +export type Ctrl = { + current: { + secureJsonFields: { + [key: string]: boolean; + }; + secureJsonData?: { + [key: string]: string; + }; + password?: string; + basicAuthPassword?: string; + }; +}; + +export const createResetHandler = (ctrl: Ctrl, field: PasswordFieldEnum) => ( + event: SyntheticEvent +) => { + event.preventDefault(); + // Reset also normal plain text password to remove it and only save it in secureJsonData. + ctrl.current[field] = undefined; + ctrl.current.secureJsonFields[field] = false; + ctrl.current.secureJsonData = ctrl.current.secureJsonData || {}; + ctrl.current.secureJsonData[field] = ''; +}; + +export const createChangeHandler = (ctrl: any, field: PasswordFieldEnum) => ( + event: SyntheticEvent +) => { + ctrl.current.secureJsonData = ctrl.current.secureJsonData || {}; + ctrl.current.secureJsonData[field] = event.currentTarget.value; +}; diff --git a/public/app/features/explore/AdHocFilter.tsx b/public/app/features/explore/AdHocFilter.tsx new file mode 100644 index 0000000..bec568d --- /dev/null +++ b/public/app/features/explore/AdHocFilter.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { LegacyForms, useStyles } from '@grafana/ui'; +const { Select } = LegacyForms; +import { css, cx } from '@emotion/css'; +import { GrafanaTheme, SelectableValue } from '@grafana/data'; + +const getStyles = (theme: GrafanaTheme) => ({ + keyValueContainer: css` + label: key-value-container; + display: flex; + flex-flow: row nowrap; + `, +}); + +enum ChangeType { + Key = 'key', + Value = 'value', + Operator = 'operator', +} + +export interface Props { + keys: string[]; + keysPlaceHolder?: string; + initialKey?: string; + initialOperator?: string; + initialValue?: string; + values?: string[]; + valuesPlaceHolder?: string; + onKeyChanged: (key: string) => void; + onValueChanged: (value: string) => void; + onOperatorChanged: (operator: string) => void; +} + +export const AdHocFilter: React.FunctionComponent = (props) => { + const styles = useStyles(getStyles); + + const onChange = (changeType: ChangeType) => (item: SelectableValue) => { + const { onKeyChanged, onValueChanged, onOperatorChanged } = props; + + if (!item.value) { + return; + } + + switch (changeType) { + case ChangeType.Key: + onKeyChanged(item.value); + break; + case ChangeType.Operator: + onOperatorChanged(item.value); + break; + case ChangeType.Value: + onValueChanged(item.value); + break; + } + }; + + const stringToOption = (value: string) => ({ label: value, value: value }); + + const { keys, initialKey, keysPlaceHolder, initialOperator, values, initialValue, valuesPlaceHolder } = props; + const operators = ['=', '!=']; + const keysAsOptions = keys ? keys.map(stringToOption) : []; + const selectedKey = initialKey ? keysAsOptions.filter((option) => option.value === initialKey) : undefined; + const valuesAsOptions = values ? values.map(stringToOption) : []; + const selectedValue = initialValue ? valuesAsOptions.filter((option) => option.value === initialValue) : undefined; + const operatorsAsOptions = operators.map(stringToOption); + const selectedOperator = initialOperator + ? operatorsAsOptions.filter((option) => option.value === initialOperator) + : undefined; + + return ( +
    + + +
    +
    + +
    +
    + +
    +
    + +
    +
    Annotation Query Format
    +An annotation is an event that is overlaid on top of graphs. The query can have up to four columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time for the annotation event time. Use epoch time or any native date data type. +- column with alias: timeend for the annotation event end time. Use epoch time or any native date data type. +- column with alias: text for the annotation text. +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2'. + + +Macros: +- $__time(column) -> column AS time +- $__timeEpoch(column) -> DATEDIFF(second, '1970-01-01', column) AS time +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 +- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> '2017-04-21T05:01:17Z' +- $__timeTo() -> '2017-04-21T05:01:17Z' +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +- $__unixEpochNanoFrom() -> 1494410783152415214 +- $__unixEpochNanoTo() -> 1494497183142514872 +
    +
    +
    diff --git a/public/app/plugins/datasource/mssql/partials/config.html b/public/app/plugins/datasource/mssql/partials/config.html new file mode 100644 index 0000000..af18c63 --- /dev/null +++ b/public/app/plugins/datasource/mssql/partials/config.html @@ -0,0 +1,125 @@ + +

    MS SQL connection

    + +
    +
    + Host + +
    + +
    + Database + +
    + +
    + +
    + + +
      +
    • SQL Server Authentication This is the default mechanism to connect to MS SQL Server. Enter the SQL Server Authentication login or the Windows Authentication login in the DOMAIN\User format.
    • +
    • Windows Authentication Windows Integrated Security - single sign on for users who are already logged onto Windows and have enabled this option for MS SQL Server.
    • +
    +
    +
    +
    +
    +
    + User + +
    +
    + +
    +
    + +
    + +
    + + + Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server. +
      +
    • disable - Data sent between client and server is not encrypted.
    • +
    • false - Data sent between client and server is not encrypted beyond the login packet. (default)
    • +
    • true - Data sent between client and server is encrypted.
    • +
    + If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect. +
    +
    +
    +
    + +

    Connection limits

    + +
    +
    + Max open + + + The maximum number of open connections to the database. If Max idle connections is greater than 0 and the + Max open connections is less than Max idle connections, then Max idle connections will be + reduced to match the Max open connections limit. If set to 0, there is no limit on the number of open + connections. + +
    +
    + Max idle + + + The maximum number of connections in the idle connection pool. If Max open connections is greater than 0 but + less than the Max idle connections, then the Max idle connections will be reduced to match the + Max open connections limit. If set to 0, no idle connections are retained. + +
    +
    + Max lifetime + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever. + +
    +
    + +

    MS SQL details

    + +
    +
    +
    + Min time interval + + + A lower limit for the auto group by time interval. Recommended to be set to write frequency, + for example 1m if your data is written every minute. + +
    +
    +
    + +
    +
    +
    User Permission
    +

    + The database user should only be granted SELECT permissions on the specified database and tables you want to query. + Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements + like USE otherdb; and DROP TABLE user; would be executed. To protect against this we + highly recommmend you create a specific MS SQL user with restricted permissions. +

    +
    +
    + diff --git a/public/app/plugins/datasource/mssql/partials/query.editor.html b/public/app/plugins/datasource/mssql/partials/query.editor.html new file mode 100644 index 0000000..a1c71e0 --- /dev/null +++ b/public/app/plugins/datasource/mssql/partials/query.editor.html @@ -0,0 +1,90 @@ + +
    +
    + + +
    +
    + +
    +
    + +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    Time series:
    +- return column named time (in UTC), as a unix time stamp or any sql native date data type. You can use the macros below.
    +- any other columns returned will be the time point values.
    +Optional:
    +  - return column named metric to represent the series name.
    +  - If multiple value columns are returned the metric column is used as prefix.
    +  - If no column named metric is found the column name of the value column is used as series name
    +
    +Resultsets of time series queries need to be sorted by time.
    +
    +Table:
    +- return any set of columns
    +
    +Macros:
    +- $__time(column) -> column AS time
    +- $__timeEpoch(column) -> DATEDIFF(second, '1970-01-01', column) AS time
    +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z'
    +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877
    +- $__unixEpochNanoFilter(column) ->  column >= 1494410783152415214 AND column <= 1494497183142514872
    +- $__timeGroup(column, '5m'[, fillvalue]) -> CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300.
    +     by setting fillvalue grafana will fill in missing values according to the interval
    +     fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet
    +- $__timeGroupAlias(column, '5m'[, fillvalue]) -> CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300 AS [time]
    +- $__unixEpochGroup(column,'5m') -> FLOOR(column/300)*300
    +- $__unixEpochGroupAlias(column,'5m') -> FLOOR(column/300)*300 AS [time]
    +
    +Example of group by and order by with $__timeGroup:
    +SELECT
    +  $__timeGroup(date_time_col, '1h') AS time,
    +  sum(value) as value
    +FROM yourtable
    +GROUP BY $__timeGroup(date_time_col, '1h')
    +ORDER BY 1
    +
    +Or build your own conditionals using these macros which just return the values:
    +- $__timeFrom() ->  '2017-04-21T05:01:17Z'
    +- $__timeTo() ->  '2017-04-21T05:01:17Z'
    +- $__unixEpochFrom() -> 1492750877
    +- $__unixEpochTo() -> 1492750877
    +- $__unixEpochNanoFrom() ->  1494410783152415214
    +- $__unixEpochNanoTo() ->  1494497183142514872
    +		
    +
    + +
    + +
    +
    {{ctrl.lastQueryMeta.executedQueryString}}
    +
    + +
    +
    {{ctrl.lastQueryError}}
    +
    + + diff --git a/public/app/plugins/datasource/mssql/plugin.json b/public/app/plugins/datasource/mssql/plugin.json new file mode 100644 index 0000000..333bf20 --- /dev/null +++ b/public/app/plugins/datasource/mssql/plugin.json @@ -0,0 +1,26 @@ +{ + "type": "datasource", + "name": "Microsoft SQL Server", + "id": "mssql", + "category": "sql", + + "info": { + "description": "Data source for Microsoft SQL Server compatible databases", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/sql_server_logo.svg", + "large": "img/sql_server_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/mssql/query_ctrl.ts b/public/app/plugins/datasource/mssql/query_ctrl.ts new file mode 100644 index 0000000..3dd7663 --- /dev/null +++ b/public/app/plugins/datasource/mssql/query_ctrl.ts @@ -0,0 +1,63 @@ +import { QueryCtrl } from 'app/plugins/sdk'; +import { auto } from 'angular'; +import { PanelEvents, QueryResultMeta } from '@grafana/data'; +import { MssqlQuery } from './types'; + +const defaultQuery = `SELECT + $__timeEpoch(), + as value, + as metric +FROM + +WHERE + $__timeFilter(time_column) +ORDER BY + ASC`; + +export class MssqlQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + formats: any[]; + lastQueryMeta?: QueryResultMeta; + lastQueryError?: string; + showHelp = false; + + /** @ngInject */ + constructor($scope: any, $injector: auto.IInjectorService) { + super($scope, $injector); + + this.target.format = this.target.format || 'time_series'; + this.target.alias = ''; + this.formats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + + if (!this.target.rawSql) { + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = 'SELECT 1'; + } else { + this.target.rawSql = defaultQuery; + } + } + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + } + + onDataReceived(dataList: any) { + this.lastQueryError = undefined; + this.lastQueryMeta = dataList[0]?.meta; + } + + onDataError(err: any) { + if (err.data && err.data.results) { + const queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryError = queryRes.error; + } + } + } +} diff --git a/public/app/plugins/datasource/mssql/response_parser.ts b/public/app/plugins/datasource/mssql/response_parser.ts new file mode 100644 index 0000000..559bfdc --- /dev/null +++ b/public/app/plugins/datasource/mssql/response_parser.ts @@ -0,0 +1,122 @@ +import { map } from 'lodash'; +import { AnnotationEvent, DataFrame, FieldType, MetricFindValue } from '@grafana/data'; +import { BackendDataSourceResponse, toDataQueryResponse, FetchResponse } from '@grafana/runtime'; + +export default class ResponseParser { + transformMetricFindResponse(raw: FetchResponse): MetricFindValue[] { + const frames = toDataQueryResponse(raw).data as DataFrame[]; + + if (!frames || !frames.length) { + return []; + } + + const frame = frames[0]; + + const values: MetricFindValue[] = []; + const textField = frame.fields.find((f) => f.name === '__text'); + const valueField = frame.fields.find((f) => f.name === '__value'); + + if (textField && valueField) { + for (let i = 0; i < textField.values.length; i++) { + values.push({ text: '' + textField.values.get(i), value: '' + valueField.values.get(i) }); + } + } else { + const textFields = frame.fields.filter((f) => f.type === FieldType.string); + if (textFields) { + values.push( + ...textFields + .flatMap((f) => f.values.toArray()) + .map((v) => ({ + text: '' + v, + })) + ); + } + } + + return Array.from(new Set(values.map((v) => v.text))).map((text) => ({ + text, + value: values.find((v) => v.text === text)?.value, + })); + } + + transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number): MetricFindValue[] { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({ text: rows[i][textColIndex], value: rows[i][valueColIndex] }); + } + } + + return res; + } + + transformToSimpleList(rows: any): MetricFindValue[] { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + res.push(rows[i][j]); + } + } + + const unique = Array.from(new Set(res)); + + return map(unique, (value) => { + return { text: value }; + }); + } + + findColIndex(columns: any[], colName: string) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res: any[], key: any) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + async transformAnnotationResponse(options: any, data: BackendDataSourceResponse): Promise { + const frames = toDataQueryResponse({ data: data }).data as DataFrame[]; + const frame = frames[0]; + const timeField = frame.fields.find((f) => f.name === 'time'); + + if (!timeField) { + return Promise.reject({ message: 'Missing mandatory time column (with time column alias) in annotation query.' }); + } + + const timeEndField = frame.fields.find((f) => f.name === 'timeend'); + const textField = frame.fields.find((f) => f.name === 'text'); + const tagsField = frame.fields.find((f) => f.name === 'tags'); + + const list: AnnotationEvent[] = []; + for (let i = 0; i < frame.length; i++) { + const timeEnd = timeEndField && timeEndField.values.get(i) ? Math.floor(timeEndField.values.get(i)) : undefined; + list.push({ + annotation: options.annotation, + time: Math.floor(timeField.values.get(i)), + timeEnd, + text: textField && textField.values.get(i) ? textField.values.get(i) : '', + tags: + tagsField && tagsField.values.get(i) + ? tagsField.values + .get(i) + .trim() + .split(/\s*,\s*/) + : [], + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/mssql/specs/datasource.test.ts b/public/app/plugins/datasource/mssql/specs/datasource.test.ts new file mode 100644 index 0000000..cdcddcc --- /dev/null +++ b/public/app/plugins/datasource/mssql/specs/datasource.test.ts @@ -0,0 +1,320 @@ +import { of } from 'rxjs'; +import { dataFrameToJSON, dateTime, MetricFindValue, MutableDataFrame } from '@grafana/data'; + +import { MssqlDatasource } from '../datasource'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { TimeSrvStub } from 'test/specs/helpers'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +describe('MSSQLDatasource', () => { + const templateSrv: TemplateSrv = new TemplateSrv(); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + const ctx: any = { + timeSrv: new TimeSrvStub(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ctx.instanceSettings = { name: 'mssql' }; + ctx.ds = new MssqlDatasource(ctx.instanceSettings, templateSrv, ctx.timeSrv); + }); + + describe('When performing annotationQuery', () => { + let results: any; + + const annotationName = 'MyAnno'; + + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time, text, tags from table;', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + }; + + const response = { + results: { + MyAnno: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'time', values: [1521545610656, 1521546251185, 1521546501378] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + { name: 'tags', values: ['TagA,TagB', ' TagB , TagC', null] }, + ], + }) + ), + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.annotationQuery(options).then((data: any) => { + results = data; + }); + }); + + it('should return annotation list', () => { + expect(results.length).toBe(3); + + expect(results[0].text).toBe('some text'); + expect(results[0].tags[0]).toBe('TagA'); + expect(results[0].tags[1]).toBe('TagB'); + + expect(results[1].tags[0]).toBe('TagB'); + expect(results[1].tags[1]).toBe('TagC'); + + expect(results[2].tags.length).toBe(0); + }); + }); + + describe('When performing metricFindQuery', () => { + let results: MetricFindValue[]; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + }) + ), + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.metricFindQuery(query).then((data: MetricFindValue[]) => { + results = data; + }); + }); + + it('should return list of all column values', () => { + expect(results.length).toBe(6); + expect(results[0].text).toBe('aTitle'); + expect(results[5].text).toBe('some text3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns', () => { + let results: any; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: '__value', values: ['value1', 'value2', 'value3'] }, + { name: '__text', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + ], + }) + ), + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.metricFindQuery(query).then((data: any) => { + results = data; + }); + }); + + it('should return list of as text, value', () => { + expect(results.length).toBe(3); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('value1'); + expect(results[2].text).toBe('aTitle3'); + expect(results[2].value).toBe('value3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { + let results: any; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: '__text', values: ['aTitle', 'aTitle', 'aTitle'] }, + { name: '__value', values: ['same', 'same', 'diff'] }, + ], + }) + ), + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + return ctx.ds.metricFindQuery(query).then((data: any) => { + results = data; + }); + }); + + it('should return list of unique keys', () => { + expect(results.length).toBe(1); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('same'); + }); + }); + + describe('When performing metricFindQuery', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [{ name: 'test', values: ['aTitle'] }], + }) + ), + ], + }, + }, + }; + const time = { + from: dateTime(1521545610656), + to: dateTime(1521546251185), + }; + + beforeEach(() => { + ctx.timeSrv.setTime(time); + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.metricFindQuery(query, { range: time }); + }); + + it('should pass timerange to datasourceRequest', () => { + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.from).toBe(time.from.valueOf().toString()); + expect(fetchMock.mock.calls[0][0].data.to).toBe(time.to.valueOf().toString()); + expect(fetchMock.mock.calls[0][0].data.queries.length).toBe(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe(query); + }); + }); + + describe('When interpolating variables', () => { + beforeEach(() => { + ctx.variable = { ...initialCustomVariableModelState }; + }); + + describe('and value is a string', () => { + it('should return an unquoted value', () => { + expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual('abc'); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + expect(ctx.ds.interpolateVariable(1000, ctx.variable)).toEqual(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).toEqual("'a','b','c'"); + }); + }); + + describe('and variable allows multi-value and value is a string', () => { + it('should return a quoted value', () => { + ctx.variable.multi = true; + expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'"); + }); + }); + + describe('and variable contains single quote', () => { + it('should return a quoted value', () => { + ctx.variable.multi = true; + expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a''bc'"); + }); + }); + + describe('and variable allows all and value is a string', () => { + it('should return a quoted value', () => { + ctx.variable.includeAll = true; + expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'"); + }); + }); + }); + + describe('targetContainsTemplate', () => { + it('given query that contains template variable it should return true', () => { + const rawSql = `SELECT + $__timeGroup(createdAt,'$summarize') as time, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' AND + hostname IN($host) + GROUP BY $__timeGroup(createdAt,'$summarize'), hostname + ORDER BY 1`; + const query = { + rawSql, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ctx.ds.targetContainsTemplate(query)).toBeTruthy(); + }); + + it('given query that only contains global template variable it should return false', () => { + const rawSql = `SELECT + $__timeGroup(createdAt,'$__interval') as time, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' + GROUP BY $__timeGroup(createdAt,'$summarize'), hostname + ORDER BY 1`; + const query = { + rawSql, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ctx.ds.targetContainsTemplate(query)).toBeFalsy(); + }); + }); +}); diff --git a/public/app/plugins/datasource/mssql/types.ts b/public/app/plugins/datasource/mssql/types.ts new file mode 100644 index 0000000..8359e56 --- /dev/null +++ b/public/app/plugins/datasource/mssql/types.ts @@ -0,0 +1,21 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; + +export interface MssqlQueryForInterpolation { + alias?: any; + format?: any; + rawSql?: any; + refId: any; + hide?: any; +} + +export type ResultFormat = 'time_series' | 'table'; + +export interface MssqlQuery extends DataQuery { + alias?: string; + format?: ResultFormat; + rawSql?: any; +} + +export interface MssqlOptions extends DataSourceJsonData { + timeInterval: string; +} diff --git a/public/app/plugins/datasource/mysql/README.md b/public/app/plugins/datasource/mysql/README.md new file mode 100644 index 0000000..2d843b0 --- /dev/null +++ b/public/app/plugins/datasource/mysql/README.md @@ -0,0 +1,14 @@ +# MySQL Data Source - Native Plugin + +Grafana ships with a built-in MySQL data source plugin that allows you to query and visualize data from a MySQL compatible database. + +## Adding the data source + +1. Open the side menu by clicking the Grafana icon in the top header. +2. In the side menu under the Dashboards link you should find a link named Data Sources. +3. Click the + Add data source button in the top header. +4. Select MySQL from the Type dropdown. + +Read more about it here: + +[http://docs.grafana.org/features/datasources/mysql/](http://docs.grafana.org/features/datasources/mysql/) diff --git a/public/app/plugins/datasource/mysql/datasource.ts b/public/app/plugins/datasource/mysql/datasource.ts new file mode 100644 index 0000000..8e59abc --- /dev/null +++ b/public/app/plugins/datasource/mysql/datasource.ts @@ -0,0 +1,212 @@ +import { map as _map } from 'lodash'; +import { of } from 'rxjs'; +import { catchError, map, mapTo } from 'rxjs/operators'; +import { getBackendSrv, DataSourceWithBackend, FetchResponse, BackendDataSourceResponse } from '@grafana/runtime'; +import { DataSourceInstanceSettings, ScopedVars, MetricFindValue, AnnotationEvent } from '@grafana/data'; +import MySQLQueryModel from 'app/plugins/datasource/mysql/mysql_query_model'; +import ResponseParser from './response_parser'; +import { MysqlQueryForInterpolation, MySQLOptions, MySQLQuery } from './types'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getSearchFilterScopedVar } from '../../../features/variables/utils'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; + +export class MysqlDatasource extends DataSourceWithBackend { + id: any; + name: any; + responseParser: ResponseParser; + queryModel: MySQLQueryModel; + interval: string; + + constructor( + instanceSettings: DataSourceInstanceSettings, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(instanceSettings); + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.responseParser = new ResponseParser(); + this.queryModel = new MySQLQueryModel({}); + const settingsData = instanceSettings.jsonData || ({} as MySQLOptions); + this.interval = settingsData.timeInterval || '1m'; + } + + interpolateVariable = (value: string | string[] | number, variable: any) => { + if (typeof value === 'string') { + if (variable.multi || variable.includeAll) { + const result = this.queryModel.quoteLiteral(value); + return result; + } else { + return value; + } + } + + if (typeof value === 'number') { + return value; + } + + const quotedValues = _map(value, (v: any) => { + return this.queryModel.quoteLiteral(v); + }); + return quotedValues.join(','); + }; + + interpolateVariablesInQueries( + queries: MysqlQueryForInterpolation[], + scopedVars: ScopedVars + ): MysqlQueryForInterpolation[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable), + rawQuery: true, + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + filterQuery(query: MySQLQuery): boolean { + if (query.hide) { + return false; + } + return true; + } + + applyTemplateVariables(target: MySQLQuery, scopedVars: ScopedVars): Record { + const queryModel = new MySQLQueryModel(target, this.templateSrv, scopedVars); + return { + refId: target.refId, + datasourceId: this.id, + rawSql: queryModel.render(this.interpolateVariable as any), + format: target.format, + }; + } + + async annotationQuery(options: any): Promise { + if (!options.annotation.rawQuery) { + return Promise.reject({ + message: 'Query missing in annotation definition', + }); + } + + const query = { + refId: options.annotation.name, + datasourceId: this.id, + rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), + format: 'table', + }; + + return getBackendSrv() + .fetch({ + url: '/api/ds/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [query], + }, + requestId: options.annotation.name, + }) + .pipe( + map( + async (res: FetchResponse) => + await this.responseParser.transformAnnotationResponse(options, res.data) + ) + ) + .toPromise(); + } + + metricFindQuery(query: string, optionalOptions: any): Promise { + let refId = 'tempvar'; + if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { + refId = optionalOptions.variable.name; + } + + const rawSql = this.templateSrv.replace( + query, + getSearchFilterScopedVar({ query, wildcardChar: '%', options: optionalOptions }), + this.interpolateVariable + ); + + const interpolatedQuery = { + refId: refId, + datasourceId: this.id, + rawSql, + format: 'table', + }; + + const range = this.timeSrv.timeRange(); + + return getBackendSrv() + .fetch({ + url: '/api/ds/query', + method: 'POST', + data: { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + queries: [interpolatedQuery], + }, + requestId: refId, + }) + .pipe( + map((rsp) => { + return this.responseParser.transformMetricFindResponse(rsp); + }) + ) + .toPromise(); + } + + testDatasource(): Promise { + return getBackendSrv() + .fetch({ + url: '/api/ds/query', + method: 'POST', + data: { + from: '5m', + to: 'now', + queries: [ + { + refId: 'A', + intervalMs: 1, + maxDataPoints: 1, + datasourceId: this.id, + rawSql: 'SELECT 1', + format: 'table', + }, + ], + }, + }) + .pipe( + mapTo({ status: 'success', message: 'Database Connection OK' }), + catchError((err) => { + console.error(err); + if (err.data && err.data.message) { + return of({ status: 'error', message: err.data.message }); + } else { + return of({ status: 'error', message: err.status }); + } + }) + ) + .toPromise(); + } + + targetContainsTemplate(target: any) { + let rawSql = ''; + + if (target.rawQuery) { + rawSql = target.rawSql; + } else { + const query = new MySQLQueryModel(target); + rawSql = query.buildQuery(); + } + + rawSql = rawSql.replace('$__', ''); + + return this.templateSrv.variableExists(rawSql); + } +} diff --git a/public/app/plugins/datasource/mysql/img/mysql_logo.svg b/public/app/plugins/datasource/mysql/img/mysql_logo.svg new file mode 100644 index 0000000..6d1d5c8 --- /dev/null +++ b/public/app/plugins/datasource/mysql/img/mysql_logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/mysql/meta_query.ts b/public/app/plugins/datasource/mysql/meta_query.ts new file mode 100644 index 0000000..eb03dc4 --- /dev/null +++ b/public/app/plugins/datasource/mysql/meta_query.ts @@ -0,0 +1,142 @@ +export class MysqlMetaQuery { + constructor(private target: any, private queryModel: any) {} + + getOperators(datatype: string) { + switch (datatype) { + case 'double': + case 'float': { + return ['=', '!=', '<', '<=', '>', '>=']; + } + case 'text': + case 'tinytext': + case 'mediumtext': + case 'longtext': + case 'varchar': + case 'char': { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE']; + } + default: { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN']; + } + } + } + + // quote identifier as literal to use in metadata queries + quoteIdentAsLiteral(value: string) { + return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value)); + } + + findMetricTable() { + // query that returns first table found that has a timestamp(tz) column and a float column + const query = ` + SELECT + table_name as table_name, + ( SELECT + column_name as column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN ('timestamp', 'datetime') + ORDER BY ordinal_position LIMIT 1 + ) AS time_column, + ( SELECT + column_name AS column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN('float', 'int', 'bigint') + ORDER BY ordinal_position LIMIT 1 + ) AS value_column + FROM information_schema.tables t + WHERE + t.table_schema = database() AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN ('timestamp', 'datetime') + ) AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN('float', 'int', 'bigint') + ) + LIMIT 1 +;`; + return query; + } + + buildTableConstraint(table: string) { + let query = ''; + + // check for schema qualified table + if (table.includes('.')) { + const parts = table.split('.'); + query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]); + query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]); + return query; + } else { + query = 'table_schema = database() AND table_name = ' + this.quoteIdentAsLiteral(table); + + return query; + } + } + + buildTableQuery() { + return 'SELECT table_name FROM information_schema.tables WHERE table_schema = database() ORDER BY table_name'; + } + + buildColumnQuery(type?: string) { + let query = 'SELECT column_name FROM information_schema.columns WHERE '; + query += this.buildTableConstraint(this.target.table); + + switch (type) { + case 'time': { + query += " AND data_type IN ('timestamp','datetime','bigint','int','double','float')"; + break; + } + case 'metric': { + query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')"; + break; + } + case 'value': { + query += " AND data_type IN ('bigint','int','smallint','mediumint','tinyint','double','decimal','float')"; + query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn); + break; + } + case 'group': { + query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')"; + break; + } + } + + query += ' ORDER BY column_name'; + + return query; + } + + buildValueQuery(column: string) { + let query = 'SELECT DISTINCT QUOTE(' + column + ')'; + query += ' FROM ' + this.target.table; + query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')'; + query += ' ORDER BY 1 LIMIT 100'; + return query; + } + + buildDatatypeQuery(column: string) { + let query = ` +SELECT data_type +FROM information_schema.columns +WHERE `; + query += ' table_name = ' + this.quoteIdentAsLiteral(this.target.table); + query += ' AND column_name = ' + this.quoteIdentAsLiteral(column); + return query; + } +} diff --git a/public/app/plugins/datasource/mysql/module.ts b/public/app/plugins/datasource/mysql/module.ts new file mode 100644 index 0000000..4ab4d16 --- /dev/null +++ b/public/app/plugins/datasource/mysql/module.ts @@ -0,0 +1,56 @@ +import { MysqlDatasource } from './datasource'; +import { MysqlQueryCtrl } from './query_ctrl'; +import { + createChangeHandler, + createResetHandler, + PasswordFieldEnum, +} from '../../../features/datasources/utils/passwordHandlers'; +import { MySQLQuery } from './types'; +import { DataSourcePlugin } from '@grafana/data'; + +class MysqlConfigCtrl { + static templateUrl = 'partials/config.html'; + current: any; + onPasswordReset: ReturnType; + onPasswordChange: ReturnType; + + constructor() { + this.onPasswordReset = createResetHandler(this, PasswordFieldEnum.Password); + this.onPasswordChange = createChangeHandler(this, PasswordFieldEnum.Password); + } +} + +const defaultQuery = `SELECT + UNIX_TIMESTAMP() as time_sec, + as text, + as tags + FROM
    + WHERE $__timeFilter(time_column) + ORDER BY ASC + LIMIT 100 + `; + +class MysqlAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + + declare annotation: any; + + /** @ngInject */ + constructor($scope: any) { + this.annotation = $scope.ctrl.annotation; + this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; + } +} + +export { + MysqlDatasource, + MysqlDatasource as Datasource, + MysqlQueryCtrl as QueryCtrl, + MysqlConfigCtrl as ConfigCtrl, + MysqlAnnotationsQueryCtrl as AnnotationsQueryCtrl, +}; + +export const plugin = new DataSourcePlugin(MysqlDatasource) + .setQueryCtrl(MysqlQueryCtrl) + .setConfigCtrl(MysqlConfigCtrl) + .setAnnotationQueryCtrl(MysqlAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/mysql/mysql_query_model.ts b/public/app/plugins/datasource/mysql/mysql_query_model.ts new file mode 100644 index 0000000..efa6291 --- /dev/null +++ b/public/app/plugins/datasource/mysql/mysql_query_model.ts @@ -0,0 +1,235 @@ +import { find, map } from 'lodash'; +import { TemplateSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; + +export default class MySQLQueryModel { + target: any; + templateSrv: any; + scopedVars: any; + + /** @ngInject */ + constructor(target: any, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { + this.target = target; + this.templateSrv = templateSrv; + this.scopedVars = scopedVars; + + target.format = target.format || 'time_series'; + target.timeColumn = target.timeColumn || 'time'; + target.metricColumn = target.metricColumn || 'none'; + + target.group = target.group || []; + target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }]; + target.select = target.select || [[{ type: 'column', params: ['value'] }]]; + + // handle pre query gui panels gracefully + if (!('rawQuery' in this.target)) { + if ('rawSql' in target) { + // pre query gui panel + target.rawQuery = true; + } else { + // new panel + target.rawQuery = false; + } + } + + // give interpolateQueryStr access to this + this.interpolateQueryStr = this.interpolateQueryStr.bind(this); + } + + // remove identifier quoting from identifier to use in metadata queries + unquoteIdentifier(value: string) { + if (value[0] === '"' && value[value.length - 1] === '"') { + return value.substring(1, value.length - 1).replace(/""/g, '"'); + } else { + return value; + } + } + + quoteIdentifier(value: string) { + return '"' + value.replace(/"/g, '""') + '"'; + } + + quoteLiteral(value: string) { + return "'" + value.replace(/'/g, "''") + "'"; + } + + escapeLiteral(value: any) { + return String(value).replace(/'/g, "''"); + } + + hasTimeGroup() { + return find(this.target.group, (g: any) => g.type === 'time'); + } + + hasMetricColumn() { + return this.target.metricColumn !== 'none'; + } + + interpolateQueryStr(value: string, variable: { multi: any; includeAll: any }, defaultFormatFn: any) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return this.escapeLiteral(value); + } + + if (typeof value === 'string') { + return this.quoteLiteral(value); + } + + const escapedValues = map(value, this.quoteLiteral); + return escapedValues.join(','); + } + + render(interpolate?: boolean) { + const target = this.target; + + // new query with no table set yet + if (!this.target.rawQuery && !('table' in this.target)) { + return ''; + } + + if (!target.rawQuery) { + target.rawSql = this.buildQuery(); + } + + if (interpolate) { + return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr); + } else { + return target.rawSql; + } + } + + hasUnixEpochTimecolumn() { + return ['int', 'bigint', 'double'].indexOf(this.target.timeColumnType) > -1; + } + + buildTimeColumn(alias = true) { + const timeGroup = this.hasTimeGroup(); + let query; + let macro = '$__timeGroup'; + + if (timeGroup) { + let args; + if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') { + args = timeGroup.params.join(','); + } else { + args = timeGroup.params[0]; + } + if (this.hasUnixEpochTimecolumn()) { + macro = '$__unixEpochGroup'; + } + if (alias) { + macro += 'Alias'; + } + query = macro + '(' + this.target.timeColumn + ',' + args + ')'; + } else { + query = this.target.timeColumn; + if (alias) { + query += ' AS "time"'; + } + } + + return query; + } + + buildMetricColumn() { + if (this.hasMetricColumn()) { + return this.target.metricColumn + ' AS metric'; + } + + return ''; + } + + buildValueColumns() { + let query = ''; + for (const column of this.target.select) { + query += ',\n ' + this.buildValueColumn(column); + } + + return query; + } + + buildValueColumn(column: any) { + let query = ''; + + const columnName: any = find(column, (g: any) => g.type === 'column'); + query = columnName.params[0]; + + const aggregate: any = find(column, (g: any) => g.type === 'aggregate'); + + if (aggregate) { + const func = aggregate.params[0]; + query = func + '(' + query + ')'; + } + + const alias: any = find(column, (g: any) => g.type === 'alias'); + if (alias) { + query += ' AS ' + this.quoteIdentifier(alias.params[0]); + } + + return query; + } + + buildWhereClause() { + let query = ''; + const conditions = map(this.target.where, (tag, index) => { + switch (tag.type) { + case 'macro': + return tag.name + '(' + this.target.timeColumn + ')'; + break; + case 'expression': + return tag.params.join(' '); + break; + } + }); + + if (conditions.length > 0) { + query = '\nWHERE\n ' + conditions.join(' AND\n '); + } + + return query; + } + + buildGroupClause() { + let query = ''; + let groupSection = ''; + + for (let i = 0; i < this.target.group.length; i++) { + const part = this.target.group[i]; + if (i > 0) { + groupSection += ', '; + } + if (part.type === 'time') { + groupSection += '1'; + } else { + groupSection += part.params[0]; + } + } + + if (groupSection.length) { + query = '\nGROUP BY ' + groupSection; + if (this.hasMetricColumn()) { + query += ',2'; + } + } + return query; + } + + buildQuery() { + let query = 'SELECT'; + + query += '\n ' + this.buildTimeColumn(); + if (this.hasMetricColumn()) { + query += ',\n ' + this.buildMetricColumn(); + } + query += this.buildValueColumns(); + + query += '\nFROM ' + this.target.table; + + query += this.buildWhereClause(); + query += this.buildGroupClause(); + + query += '\nORDER BY ' + this.buildTimeColumn(false); + + return query; + } +} diff --git a/public/app/plugins/datasource/mysql/partials/annotations.editor.html b/public/app/plugins/datasource/mysql/partials/annotations.editor.html new file mode 100644 index 0000000..a9530b0 --- /dev/null +++ b/public/app/plugins/datasource/mysql/partials/annotations.editor.html @@ -0,0 +1,54 @@ +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    Annotation Query Format
    +An annotation is an event that is overlaid on top of graphs. The query can have up to four columns per row, the time or time_sec column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time or time_sec for the annotation event time. Use epoch time or any native date data type. +- column with alias: timeend for the annotation event end time. Use epoch time or any native date data type. +- column with alias: text for the annotation text +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2' + + +Macros: +- $__time(column) -> UNIX_TIMESTAMP(column) as time (or as time_sec) +- $__timeEpoch(column) -> UNIX_TIMESTAMP(column) as time (or as time_sec) +- $__timeFilter(column) -> column BETWEEN FROM_UNIXTIME(1492750877) AND FROM_UNIXTIME(1492750877) +- $__unixEpochFilter(column) -> time_unix_epoch > 1492750877 AND time_unix_epoch < 1492750877 +- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> FROM_UNIXTIME(1492750877) +- $__timeTo() -> FROM_UNIXTIME(1492750877) +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +- $__unixEpochNanoFrom() -> 1494410783152415214 +- $__unixEpochNanoTo() -> 1494497183142514872 +
    +
    +
    diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html new file mode 100644 index 0000000..b9455b1 --- /dev/null +++ b/public/app/plugins/datasource/mysql/partials/config.html @@ -0,0 +1,113 @@ +

    MySQL Connection

    + +
    +
    + Host + +
    + +
    + Database + +
    + +
    +
    + User + +
    +
    + +
    +
    + +
    +
    + + +
    +
    + +
    +
    + + + + +Connection limits + +
    +
    + Max open + + + The maximum number of open connections to the database. If Max idle connections is greater than 0 and the + Max open connections is less than Max idle connections, then Max idle connections will be + reduced to match the Max open connections limit. If set to 0, there is no limit on the number of open + connections. + +
    +
    + Max idle + + + The maximum number of connections in the idle connection pool. If Max open connections is greater than 0 but + less than the Max idle connections, then the Max idle connections will be reduced to match the + Max open connections limit. If set to 0, no idle connections are retained. + +
    +
    + Max lifetime + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever.

    + This should always be lower than configured wait_timeout in MySQL. +
    +
    +
    + +

    MySQL details

    + +
    +
    +
    + Min time interval + + + A lower limit for the auto group by time interval. Recommended to be set to write frequency, + for example 1m if your data is written every minute. + +
    +
    +
    + +
    +
    +
    User Permission
    +

    + The database user should only be granted SELECT permissions on the specified database & tables you want to query. + Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements + like USE otherdb; and DROP TABLE user; would be executed. To protect against this we + Highly recommmend you create a specific MySQL user with restricted permissions. + + Checkout the MySQL Data Source Docs for more information. +

    +
    +
    diff --git a/public/app/plugins/datasource/mysql/partials/query.editor.html b/public/app/plugins/datasource/mysql/partials/query.editor.html new file mode 100644 index 0000000..a04ea78 --- /dev/null +++ b/public/app/plugins/datasource/mysql/partials/query.editor.html @@ -0,0 +1,190 @@ + + +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    + + + + + + + + +
    + +
    +
    +
    + +
    + +
    +
    + +
    + +
    + + +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    + +
    + + +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    + + + + +
    + +
    + +
    + +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    Time series:
    +- return column named time or time_sec (in UTC), as a unix time stamp or any sql native date data type. You can use the macros below.
    +- return column(s) with numeric datatype as values
    +Optional:
    +  - return column named metric to represent the series name.
    +  - If multiple value columns are returned the metric column is used as prefix.
    +  - If no column named metric is found the column name of the value column is used as series name
    +
    +Resultsets of time series queries need to be sorted by time.
    +
    +Table:
    +- return any set of columns
    +
    +Macros:
    +- $__time(column) -> UNIX_TIMESTAMP(column) as time_sec
    +- $__timeEpoch(column) -> UNIX_TIMESTAMP(column) as time_sec
    +- $__timeFilter(column) -> column BETWEEN FROM_UNIXTIME(1492750877) AND FROM_UNIXTIME(1492750877)
    +- $__unixEpochFilter(column) ->  time_unix_epoch > 1492750877 AND time_unix_epoch < 1492750877
    +- $__unixEpochNanoFilter(column) ->  column >= 1494410783152415214 AND column <= 1494497183142514872
    +- $__timeGroup(column,'5m'[, fillvalue]) -> cast(cast(UNIX_TIMESTAMP(column)/(300) as signed)*300 as signed)
    +     by setting fillvalue grafana will fill in missing values according to the interval
    +     fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet
    +- $__timeGroupAlias(column,'5m') -> cast(cast(UNIX_TIMESTAMP(column)/(300) as signed)*300 as signed) AS "time"
    +- $__unixEpochGroup(column,'5m') -> column DIV 300 * 300
    +- $__unixEpochGroupAlias(column,'5m') -> column DIV 300 * 300 AS "time"
    +
    +Example of group by and order by with $__timeGroup:
    +SELECT
    +  $__timeGroupAlias(timestamp_col, '1h'),
    +  sum(value_double) as value
    +FROM yourtable
    +GROUP BY 1
    +ORDER BY 1
    +
    +Or build your own conditionals using these macros which just return the values:
    +- $__timeFrom() -> FROM_UNIXTIME(1492750877)
    +- $__timeTo() ->  FROM_UNIXTIME(1492750877)
    +- $__unixEpochFrom() ->  1492750877
    +- $__unixEpochTo() ->  1492750877
    +- $__unixEpochNanoFrom() ->  1494410783152415214
    +- $__unixEpochNanoTo() ->  1494497183142514872
    +    
    +
    + +
    + +
    +
    {{ctrl.lastQueryMeta.executedQueryString}}
    +
    + +
    +
    {{ctrl.lastQueryError}}
    +
    + + diff --git a/public/app/plugins/datasource/mysql/plugin.json b/public/app/plugins/datasource/mysql/plugin.json new file mode 100644 index 0000000..2918371 --- /dev/null +++ b/public/app/plugins/datasource/mysql/plugin.json @@ -0,0 +1,26 @@ +{ + "type": "datasource", + "name": "MySQL", + "id": "mysql", + "category": "sql", + + "info": { + "description": "Data source for MySQL databases", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/mysql_logo.svg", + "large": "img/mysql_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/mysql/query_ctrl.ts b/public/app/plugins/datasource/mysql/query_ctrl.ts new file mode 100644 index 0000000..c33f709 --- /dev/null +++ b/public/app/plugins/datasource/mysql/query_ctrl.ts @@ -0,0 +1,636 @@ +import { clone, filter, find, findIndex, indexOf, map } from 'lodash'; +import appEvents from 'app/core/app_events'; +import { MysqlMetaQuery } from './meta_query'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { SqlPart } from 'app/core/components/sql_part/sql_part'; +import MySQLQueryModel from './mysql_query_model'; +import sqlPart from './sql_part'; +import { auto } from 'angular'; +import { PanelEvents, QueryResultMeta } from '@grafana/data'; +import { VariableWithMultiSupport } from 'app/features/variables/types'; +import { TemplateSrv } from '@grafana/runtime'; +import { ShowConfirmModalEvent } from '../../../types/events'; + +const defaultQuery = `SELECT + UNIX_TIMESTAMP() as time_sec, + as value, + as metric +FROM
    +WHERE $__timeFilter(time_column) +ORDER BY ASC +`; + +export class MysqlQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + formats: any[]; + lastQueryError?: string; + showHelp!: boolean; + + queryModel: MySQLQueryModel; + metaBuilder: MysqlMetaQuery; + lastQueryMeta?: QueryResultMeta; + tableSegment: any; + whereAdd: any; + timeColumnSegment: any; + metricColumnSegment: any; + selectMenu: any[] = []; + selectParts: SqlPart[][] = []; + groupParts: SqlPart[] = []; + whereParts: SqlPart[] = []; + groupAdd: any; + + /** @ngInject */ + constructor( + $scope: any, + $injector: auto.IInjectorService, + private templateSrv: TemplateSrv, + private uiSegmentSrv: any + ) { + super($scope, $injector); + + this.target = this.target; + this.queryModel = new MySQLQueryModel(this.target, templateSrv, this.panel.scopedVars); + this.metaBuilder = new MysqlMetaQuery(this.target, this.queryModel); + this.updateProjection(); + + this.formats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + + if (!this.target.rawSql) { + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = 'SELECT 1'; + this.target.rawQuery = true; + } else { + this.target.rawSql = defaultQuery; + this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then((result: any) => { + if (result.length > 0) { + this.target.table = result[0].text; + let segment = this.uiSegmentSrv.newSegment(this.target.table); + this.tableSegment.html = segment.html; + this.tableSegment.value = segment.value; + + this.target.timeColumn = result[1].text; + segment = this.uiSegmentSrv.newSegment(this.target.timeColumn); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + + this.target.timeColumnType = 'timestamp'; + this.target.select = [[{ type: 'column', params: [result[2].text] }]]; + this.updateProjection(); + this.updateRawSqlAndRefresh(); + } + }); + } + } + + if (!this.target.table) { + this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true }); + } else { + this.tableSegment = uiSegmentSrv.newSegment(this.target.table); + } + + this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn); + this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn); + + this.buildSelectMenu(); + this.whereAdd = this.uiSegmentSrv.newPlusButton(); + this.groupAdd = this.uiSegmentSrv.newPlusButton(); + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + } + + updateRawSqlAndRefresh() { + if (!this.target.rawQuery) { + this.target.rawSql = this.queryModel.buildQuery(); + } + + this.panelCtrl.refresh(); + } + + updateProjection() { + this.selectParts = map(this.target.select, (parts: any) => { + return map(parts, sqlPart.create).filter((n) => n); + }); + this.whereParts = map(this.target.where, sqlPart.create).filter((n) => n); + this.groupParts = map(this.target.group, sqlPart.create).filter((n) => n); + } + + updatePersistedParts() { + this.target.select = map(this.selectParts, (selectParts) => { + return map(selectParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + }); + this.target.where = map(this.whereParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params }; + }); + this.target.group = map(this.groupParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + } + + buildSelectMenu() { + const aggregates = { + text: 'Aggregate Functions', + value: 'aggregate', + submenu: [ + { text: 'Average', value: 'avg' }, + { text: 'Count', value: 'count' }, + { text: 'Maximum', value: 'max' }, + { text: 'Minimum', value: 'min' }, + { text: 'Sum', value: 'sum' }, + { text: 'Standard deviation', value: 'stddev' }, + { text: 'Variance', value: 'variance' }, + ], + }; + + this.selectMenu.push(aggregates); + this.selectMenu.push({ text: 'Alias', value: 'alias' }); + this.selectMenu.push({ text: 'Column', value: 'column' }); + } + + toggleEditorMode() { + if (this.target.rawQuery) { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Warning', + text2: 'Switching to query builder may overwrite your raw SQL.', + icon: 'exclamation-triangle', + yesText: 'Switch', + onConfirm: () => { + this.target.rawQuery = !this.target.rawQuery; + }, + }) + ); + } else { + this.target.rawQuery = !this.target.rawQuery; + } + } + + resetPlusButton(button: { html: any; value: any }) { + const plusButton = this.uiSegmentSrv.newPlusButton(); + button.html = plusButton.html; + button.value = plusButton.value; + } + + getTableSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildTableQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + tableChanged() { + this.target.table = this.tableSegment.value; + this.target.where = []; + this.target.group = []; + this.updateProjection(); + + const segment = this.uiSegmentSrv.newSegment('none'); + this.metricColumnSegment.html = segment.html; + this.metricColumnSegment.value = segment.value; + this.target.metricColumn = 'none'; + + const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then((result: any) => { + // check if time column is still valid + if (result.length > 0 && !find(result, (r: any) => r.text === this.target.timeColumn)) { + const segment = this.uiSegmentSrv.newSegment(result[0].text); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + } + return this.timeColumnChanged(false); + }); + const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then((result: any) => { + if (result.length > 0) { + this.target.select = [[{ type: 'column', params: [result[0].text] }]]; + this.updateProjection(); + } + }); + + Promise.all([task1, task2]).then(() => { + this.updateRawSqlAndRefresh(); + }); + } + + getTimeColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('time')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + timeColumnChanged(refresh?: boolean) { + this.target.timeColumn = this.timeColumnSegment.value; + return this.datasource + .metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)) + .then((result: any) => { + if (result.length === 1) { + if (this.target.timeColumnType !== result[0].text) { + this.target.timeColumnType = result[0].text; + } + let partModel; + if (this.queryModel.hasUnixEpochTimecolumn()) { + partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] }); + } else { + partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] }); + } + + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + } + + this.updatePersistedParts(); + if (refresh !== false) { + this.updateRawSqlAndRefresh(); + } + }); + } + + getMetricColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('metric')) + .then(this.transformToSegments({ addNone: true })) + .catch(this.handleQueryError.bind(this)); + } + + metricColumnChanged() { + this.target.metricColumn = this.metricColumnSegment.value; + this.updateRawSqlAndRefresh(); + } + + onDataReceived(dataList: any) { + this.lastQueryError = undefined; + this.lastQueryMeta = dataList[0]?.meta; + } + + onDataError(err: any) { + if (err.data && err.data.results) { + const queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryError = queryRes.error; + } + } + } + + transformToSegments(config: any) { + return (results: any) => { + const segments = map(results, (segment) => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (config.addTemplateVars) { + for (const variable of this.templateSrv.getVariables()) { + let value; + value = '$' + variable.name; + if (config.templateQuoter && ((variable as unknown) as VariableWithMultiSupport).multi === false) { + value = config.templateQuoter(value); + } + + segments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'template', + value: value, + expandable: true, + }) + ); + } + } + + if (config.addNone) { + segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true })); + } + + return segments; + }; + } + + findAggregateIndex(selectParts: any) { + return findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile'); + } + + findWindowIndex(selectParts: any) { + return findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window'); + } + + addSelectPart(selectParts: any[], item: { value: any }, subItem: { type: any; value: any }) { + let partType = item.value; + if (subItem && subItem.type) { + partType = subItem.type; + } + let partModel = sqlPart.create({ type: partType }); + if (subItem) { + partModel.params[0] = subItem.value; + } + let addAlias = false; + + switch (partType) { + case 'column': + const parts = map(selectParts, (part: any) => { + return sqlPart.create({ type: part.def.type, params: clone(part.params) }); + }); + this.selectParts.push(parts); + break; + case 'percentile': + case 'aggregate': + // add group by if no group by yet + if (this.target.group.length === 0) { + this.addGroup('time', '$__interval'); + } + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + // replace current aggregation + selectParts[aggIndex] = partModel; + } else { + selectParts.splice(1, 0, partModel); + } + if (!find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'moving_window': + case 'window': + const windowIndex = this.findWindowIndex(selectParts); + if (windowIndex !== -1) { + // replace current window function + selectParts[windowIndex] = partModel; + } else { + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + selectParts.splice(aggIndex + 1, 0, partModel); + } else { + selectParts.splice(1, 0, partModel); + } + } + if (!find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'alias': + addAlias = true; + break; + } + + if (addAlias) { + // set initial alias name to column name + partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] }); + if (selectParts[selectParts.length - 1].def.type === 'alias') { + selectParts[selectParts.length - 1] = partModel; + } else { + selectParts.push(partModel); + } + } + + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + } + + removeSelectPart(selectParts: any, part: { def: { type: string } }) { + if (part.def.type === 'column') { + // remove all parts of column unless its last column + if (this.selectParts.length > 1) { + const modelsIndex = indexOf(this.selectParts, selectParts); + this.selectParts.splice(modelsIndex, 1); + } + } else { + const partIndex = indexOf(selectParts, part); + selectParts.splice(partIndex, 1); + } + + this.updatePersistedParts(); + } + + handleSelectPartEvent(selectParts: any, part: { def: any }, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + switch (part.def.type) { + // case 'aggregate': + // return this.datasource + // .metricFindQuery(this.metaBuilder.buildAggregateQuery()) + // .then(this.transformToSegments({})) + // .catch(this.handleQueryError.bind(this)); + case 'column': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('value')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeSelectPart(selectParts, part); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + handleGroupPartEvent(part: any, index: any, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeGroup(part, index); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + addGroup(partType: string, value: string) { + let params = [value]; + if (partType === 'time') { + params = ['$__interval', 'none']; + } + const partModel = sqlPart.create({ type: partType, params: params }); + + if (partType === 'time') { + // put timeGroup at start + this.groupParts.splice(0, 0, partModel); + } else { + this.groupParts.push(partModel); + } + + // add aggregates when adding group by + for (const selectParts of this.selectParts) { + if (!selectParts.some((part) => part.def.type === 'aggregate')) { + const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] }); + selectParts.splice(1, 0, aggregate); + if (!selectParts.some((part) => part.def.type === 'alias')) { + const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] }); + selectParts.push(alias); + } + } + } + + this.updatePersistedParts(); + } + + removeGroup(part: { def: { type: string } }, index: number) { + if (part.def.type === 'time') { + // remove aggregations + this.selectParts = map(this.selectParts, (s: any) => { + return filter(s, (part: any) => { + if (part.def.type === 'aggregate' || part.def.type === 'percentile') { + return false; + } + return true; + }); + }); + } + + this.groupParts.splice(index, 1); + this.updatePersistedParts(); + } + + handleWherePartEvent(whereParts: any, part: any, evt: any, index: any) { + switch (evt.name) { + case 'get-param-options': { + switch (evt.param.name) { + case 'left': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'right': + if (['int', 'bigint', 'double', 'datetime'].indexOf(part.datatype) > -1) { + // don't do value lookups for numerical fields + return Promise.resolve([]); + } else { + return this.datasource + .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0])) + .then( + this.transformToSegments({ + addTemplateVars: true, + templateQuoter: (v: string) => { + return this.queryModel.quoteLiteral(v); + }, + }) + ) + .catch(this.handleQueryError.bind(this)); + } + case 'op': + return Promise.resolve(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype))); + default: + return Promise.resolve([]); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => { + if (d.length === 1) { + part.datatype = d[0].text; + } + }); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + // remove element + whereParts.splice(index, 1); + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + getWhereOptions() { + const options = []; + if (this.queryModel.hasUnixEpochTimecolumn()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' })); + } else { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' })); + } + options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' })); + return Promise.resolve(options); + } + + addWhereAction(part: any, index: number) { + switch (this.whereAdd.type) { + case 'macro': { + const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] }); + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + break; + } + default: { + this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] })); + } + } + + this.updatePersistedParts(); + this.resetPlusButton(this.whereAdd); + this.updateRawSqlAndRefresh(); + } + + getGroupOptions() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('group')) + .then((tags: any) => { + const options = []; + if (!this.queryModel.hasTimeGroup()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' })); + } + for (const tag of tags) { + options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text })); + } + return options; + }) + .catch(this.handleQueryError.bind(this)); + } + + addGroupAction() { + switch (this.groupAdd.value) { + default: { + this.addGroup(this.groupAdd.type, this.groupAdd.value); + } + } + + this.resetPlusButton(this.groupAdd); + this.updateRawSqlAndRefresh(); + } + + handleQueryError(err: any): any[] { + this.error = err.message || 'Failed to issue metric query'; + return []; + } +} diff --git a/public/app/plugins/datasource/mysql/response_parser.ts b/public/app/plugins/datasource/mysql/response_parser.ts new file mode 100644 index 0000000..be82c9c --- /dev/null +++ b/public/app/plugins/datasource/mysql/response_parser.ts @@ -0,0 +1,126 @@ +import { map } from 'lodash'; +import { AnnotationEvent, DataFrame, FieldType, MetricFindValue } from '@grafana/data'; +import { BackendDataSourceResponse, FetchResponse, toDataQueryResponse } from '@grafana/runtime'; + +export default class ResponseParser { + transformMetricFindResponse(raw: FetchResponse): MetricFindValue[] { + const frames = toDataQueryResponse(raw).data as DataFrame[]; + + if (!frames || !frames.length) { + return []; + } + + const frame = frames[0]; + + const values: MetricFindValue[] = []; + const textField = frame.fields.find((f) => f.name === '__text'); + const valueField = frame.fields.find((f) => f.name === '__value'); + + if (textField && valueField) { + for (let i = 0; i < textField.values.length; i++) { + values.push({ text: '' + textField.values.get(i), value: '' + valueField.values.get(i) }); + } + } else { + const textFields = frame.fields.filter((f) => f.type === FieldType.string); + if (textFields) { + values.push( + ...textFields + .flatMap((f) => f.values.toArray()) + .map((v) => ({ + text: '' + v, + })) + ); + } + } + + return Array.from(new Set(values.map((v) => v.text))).map((text) => ({ + text, + value: values.find((v) => v.text === text)?.value, + })); + } + + transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number): MetricFindValue[] { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({ text: rows[i][textColIndex], value: rows[i][valueColIndex] }); + } + } + + return res; + } + + transformToSimpleList(rows: any): MetricFindValue[] { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + res.push(rows[i][j]); + } + } + + const unique = Array.from(new Set(res)); + + return map(unique, (value) => { + return { text: value }; + }); + } + + findColIndex(columns: any[], colName: string) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res: any[], key: any) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + async transformAnnotationResponse(options: any, data: BackendDataSourceResponse): Promise { + const frames = toDataQueryResponse({ data: data }).data as DataFrame[]; + const frame = frames[0]; + const timeField = frame.fields.find((f) => f.name === 'time' || f.name === 'time_sec'); + + if (!timeField) { + throw new Error('Missing mandatory time column (with time column alias) in annotation query'); + } + + if (frame.fields.find((f) => f.name === 'title')) { + throw new Error('The title column for annotations is deprecated, now only a column named text is returned'); + } + + const timeEndField = frame.fields.find((f) => f.name === 'timeend'); + const textField = frame.fields.find((f) => f.name === 'text'); + const tagsField = frame.fields.find((f) => f.name === 'tags'); + + const list: AnnotationEvent[] = []; + for (let i = 0; i < frame.length; i++) { + const timeEnd = timeEndField && timeEndField.values.get(i) ? Math.floor(timeEndField.values.get(i)) : undefined; + list.push({ + annotation: options.annotation, + time: Math.floor(timeField.values.get(i)), + timeEnd, + text: textField && textField.values.get(i) ? textField.values.get(i) : '', + tags: + tagsField && tagsField.values.get(i) + ? tagsField.values + .get(i) + .trim() + .split(/\s*,\s*/) + : [], + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/mysql/specs/datasource.test.ts b/public/app/plugins/datasource/mysql/specs/datasource.test.ts new file mode 100644 index 0000000..7b2f399 --- /dev/null +++ b/public/app/plugins/datasource/mysql/specs/datasource.test.ts @@ -0,0 +1,402 @@ +import { of } from 'rxjs'; +import { + dataFrameToJSON, + DataQueryRequest, + DataSourceInstanceSettings, + dateTime, + MutableDataFrame, + toUtc, +} from '@grafana/data'; + +import { MysqlDatasource } from '../datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { FetchResponse, setBackendSrv } from '@grafana/runtime'; +import { MySQLOptions, MySQLQuery } from './../types'; + +describe('MySQLDatasource', () => { + const setupTextContext = (response: any) => { + jest.clearAllMocks(); + setBackendSrv(backendSrv); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + const instanceSettings = ({ + jsonData: { + defaultProject: 'testproject', + }, + } as unknown) as DataSourceInstanceSettings; + const templateSrv: TemplateSrv = new TemplateSrv(); + const variable = { ...initialCustomVariableModelState }; + const raw = { + from: toUtc('2018-04-25 10:00'), + to: toUtc('2018-04-25 11:00'), + }; + const timeSrvMock: any = { + timeRange: () => ({ + from: raw.from, + to: raw.to, + raw: raw, + }), + }; + fetchMock.mockImplementation((options) => of(createFetchResponse(response))); + + const ds = new MysqlDatasource(instanceSettings, templateSrv, timeSrvMock); + + return { ds, variable, templateSrv, fetchMock }; + }; + + describe('When performing a query with hidden target', () => { + it('should return empty result and backendSrv.fetch should not be called', async () => { + const options = ({ + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'table', + rawQuery: true, + rawSql: 'select time, metric, value from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + hide: true, + }, + ], + } as unknown) as DataQueryRequest; + + const { ds, fetchMock } = setupTextContext({}); + + await expect(ds.query(options)).toEmitValuesWith((received) => { + expect(received[0]).toEqual({ data: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + }); + + describe('When performing annotationQuery', () => { + let results: any; + const annotationName = 'MyAnno'; + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time_sec, text, tags from table;', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + }; + const response = { + results: { + MyAnno: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'time_sec', values: [1432288355, 1432288390, 1432288400] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + { name: 'tags', values: ['TagA,TagB', ' TagB , TagC', null] }, + ], + }) + ), + ], + }, + }, + }; + + beforeEach(async () => { + const { ds } = setupTextContext(response); + const data = await ds.annotationQuery(options); + results = data; + }); + + it('should return annotation list', async () => { + expect(results.length).toBe(3); + expect(results[0].text).toBe('some text'); + expect(results[0].tags[0]).toBe('TagA'); + expect(results[0].tags[1]).toBe('TagB'); + expect(results[1].tags[0]).toBe('TagB'); + expect(results[1].tags[1]).toBe('TagC'); + expect(results[2].tags.length).toBe(0); + }); + }); + + describe('When performing metricFindQuery', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + it('should return list of all column values', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(6); + expect(results[0].text).toBe('aTitle'); + expect(results[5].text).toBe('some text3'); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + it('should return list of all column values', async () => { + const { ds, fetchMock } = setupTextContext(response); + const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' }); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe( + "select title from atable where title LIKE 'aTit%'" + ); + expect(results.length).toBe(6); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + it('should return list of all column values', async () => { + const { ds, fetchMock } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'"); + expect(results.length).toBe(6); + }); + }); + + describe('When performing metricFindQuery with key, value columns', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: '__value', values: ['value1', 'value2', 'value3'] }, + { name: '__text', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + it('should return list of as text, value', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(3); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('value1'); + expect(results[2].text).toBe('aTitle3'); + expect(results[2].value).toBe('value3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: '__text', values: ['aTitle', 'aTitle', 'aTitle'] }, + { name: '__value', values: ['same', 'same', 'diff'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + it('should return list of unique keys', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(1); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('same'); + }); + }); + + describe('When interpolating variables', () => { + describe('and value is a string', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTextContext({}); + expect(ds.interpolateVariable('abc', variable)).toEqual('abc'); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTextContext({}); + expect(ds.interpolateVariable(1000, variable)).toEqual(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + const { ds, variable } = setupTextContext({}); + expect(ds.interpolateVariable(['a', 'b', 'c'], variable)).toEqual("'a','b','c'"); + }); + }); + + describe('and variable allows multi-value and value is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTextContext({}); + variable.multi = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + + describe('and variable contains single quote', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTextContext({}); + variable.multi = true; + expect(ds.interpolateVariable("a'bc", variable)).toEqual("'a''bc'"); + }); + }); + + describe('and variable allows all and value is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTextContext({}); + variable.includeAll = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + }); + + describe('targetContainsTemplate', () => { + it('given query that contains template variable it should return true', () => { + const { ds, templateSrv } = setupTextContext({}); + const rawSql = `SELECT + $__timeGroup(createdAt,'$summarize') as time_sec, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' AND + hostname IN($host) + GROUP BY 1, 3 + ORDER BY 1`; + const query = { + rawSql, + rawQuery: true, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ds.targetContainsTemplate(query)).toBeTruthy(); + }); + + it('given query that only contains global template variable it should return false', () => { + const { ds, templateSrv } = setupTextContext({}); + const rawSql = `SELECT + $__timeGroup(createdAt,'$__interval') as time_sec, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' + GROUP BY 1, 3 + ORDER BY 1`; + const query = { + rawSql, + rawQuery: true, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ds.targetContainsTemplate(query)).toBeFalsy(); + }); + }); +}); + +const createFetchResponse = (data: T): FetchResponse => ({ + data, + status: 200, + url: 'http://localhost:3000/api/query', + config: { url: 'http://localhost:3000/api/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: ({} as unknown) as Headers, + ok: true, +}); diff --git a/public/app/plugins/datasource/mysql/sql_part.ts b/public/app/plugins/datasource/mysql/sql_part.ts new file mode 100644 index 0000000..2b6bd66 --- /dev/null +++ b/public/app/plugins/datasource/mysql/sql_part.ts @@ -0,0 +1,86 @@ +import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part'; + +const index: any[] = []; + +function createPart(part: any): any { + const def = index[part.type]; + if (!def) { + return null; + } + + return new SqlPart(part, def); +} + +function register(options: any) { + index[options.type] = new SqlPartDef(options); +} + +register({ + type: 'column', + style: 'label', + params: [{ type: 'column', dynamicLookup: true }], + defaultParams: ['value'], +}); + +register({ + type: 'expression', + style: 'expression', + label: 'Expr:', + params: [ + { name: 'left', type: 'string', dynamicLookup: true }, + { name: 'op', type: 'string', dynamicLookup: true }, + { name: 'right', type: 'string', dynamicLookup: true }, + ], + defaultParams: ['value', '=', 'value'], +}); + +register({ + type: 'macro', + style: 'label', + label: 'Macro:', + params: [], + defaultParams: [], +}); + +register({ + type: 'aggregate', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'], + }, + ], + defaultParams: ['avg'], +}); + +register({ + type: 'alias', + style: 'label', + params: [{ name: 'name', type: 'string', quote: 'double' }], + defaultParams: ['alias'], +}); + +register({ + type: 'time', + style: 'function', + label: 'time', + params: [ + { + name: 'interval', + type: 'interval', + options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + { + name: 'fill', + type: 'string', + options: ['none', 'NULL', 'previous', '0'], + }, + ], + defaultParams: ['$__interval', 'none'], +}); + +export default { + create: createPart, +}; diff --git a/public/app/plugins/datasource/mysql/types.ts b/public/app/plugins/datasource/mysql/types.ts new file mode 100644 index 0000000..2f24c8e --- /dev/null +++ b/public/app/plugins/datasource/mysql/types.ts @@ -0,0 +1,20 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; +export interface MysqlQueryForInterpolation { + alias?: any; + format?: any; + rawSql?: any; + refId: any; + hide?: any; +} + +export interface MySQLOptions extends DataSourceJsonData { + timeInterval: string; +} + +export type ResultFormat = 'time_series' | 'table'; + +export interface MySQLQuery extends DataQuery { + alias?: string; + format?: ResultFormat; + rawSql?: any; +} diff --git a/public/app/plugins/datasource/opentsdb/README.md b/public/app/plugins/datasource/opentsdb/README.md new file mode 100644 index 0000000..4afd5dc --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/README.md @@ -0,0 +1,7 @@ +# OpenTSDB Data Source - Native Plugin + +Grafana ships with **built in** support for OpenTSDB, a scalable, distributed time series database. + +Read more about it here: + +[http://docs.grafana.org/datasources/opentsdb/](http://docs.grafana.org/datasources/opentsdb/) diff --git a/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx b/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx new file mode 100644 index 0000000..dc0bc71 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { OpenTsdbDetails } from './OpenTsdbDetails'; +import { OpenTsdbOptions } from '../types'; + +export const ConfigEditor = (props: DataSourcePluginOptionsEditorProps) => { + const { options, onOptionsChange } = props; + + return ( + <> + + + + ); +}; diff --git a/public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx b/public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx new file mode 100644 index 0000000..0457fe6 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx @@ -0,0 +1,82 @@ +import React, { SyntheticEvent } from 'react'; +import { InlineFormLabel, LegacyForms } from '@grafana/ui'; +const { Select, Input } = LegacyForms; +import { DataSourceSettings, SelectableValue } from '@grafana/data'; +import { OpenTsdbOptions } from '../types'; + +const tsdbVersions = [ + { label: '<=2.1', value: 1 }, + { label: '==2.2', value: 2 }, + { label: '==2.3', value: 3 }, +]; + +const tsdbResolutions = [ + { label: 'second', value: 1 }, + { label: 'millisecond', value: 2 }, +]; + +interface Props { + value: DataSourceSettings; + onChange: (value: DataSourceSettings) => void; +} + +export const OpenTsdbDetails = (props: Props) => { + const { onChange, value } = props; + + return ( + <> +
    OpenTSDB settings
    +
    + Version + resolution.value === value.jsonData.tsdbResolution) ?? + tsdbResolutions[0] + } + onChange={onSelectChangeHandler('tsdbResolution', value, onChange)} + /> +
    +
    + Lookup Limit + +
    + + ); +}; + +const onSelectChangeHandler = (key: keyof OpenTsdbOptions, value: Props['value'], onChange: Props['onChange']) => ( + newValue: SelectableValue +) => { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + [key]: newValue.value, + }, + }); +}; + +const onInputChangeHandler = (key: keyof OpenTsdbOptions, value: Props['value'], onChange: Props['onChange']) => ( + event: SyntheticEvent +) => { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + [key]: event.currentTarget.value, + }, + }); +}; diff --git a/public/app/plugins/datasource/opentsdb/datasource.d.ts b/public/app/plugins/datasource/opentsdb/datasource.d.ts new file mode 100644 index 0000000..cd73e95 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/datasource.d.ts @@ -0,0 +1,2 @@ +declare var OpenTsDatasource: any; +export default OpenTsDatasource; diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts new file mode 100644 index 0000000..acfb643 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/datasource.ts @@ -0,0 +1,576 @@ +import angular from 'angular'; +import { + clone, + compact, + each, + every, + filter, + findIndex, + has, + includes, + isArray, + isEmpty, + toPairs, + map as _map, +} from 'lodash'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { FetchResponse, getBackendSrv } from '@grafana/runtime'; +import { + AnnotationEvent, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + dateMath, + ScopedVars, +} from '@grafana/data'; + +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { OpenTsdbOptions, OpenTsdbQuery } from './types'; + +export default class OpenTsDatasource extends DataSourceApi { + type: any; + url: any; + name: any; + withCredentials: any; + basicAuth: any; + tsdbVersion: any; + tsdbResolution: any; + lookupLimit: any; + tagKeys: any; + + aggregatorsPromise: any; + filterTypesPromise: any; + + constructor(instanceSettings: any, private readonly templateSrv: TemplateSrv = getTemplateSrv()) { + super(instanceSettings); + this.type = 'opentsdb'; + this.url = instanceSettings.url; + this.name = instanceSettings.name; + this.withCredentials = instanceSettings.withCredentials; + this.basicAuth = instanceSettings.basicAuth; + instanceSettings.jsonData = instanceSettings.jsonData || {}; + this.tsdbVersion = instanceSettings.jsonData.tsdbVersion || 1; + this.tsdbResolution = instanceSettings.jsonData.tsdbResolution || 1; + this.lookupLimit = instanceSettings.jsonData.lookupLimit || 1000; + this.tagKeys = {}; + + this.aggregatorsPromise = null; + this.filterTypesPromise = null; + } + + // Called once per panel (graph) + query(options: DataQueryRequest): Observable { + const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); + const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); + const qs: any[] = []; + + each(options.targets, (target) => { + if (!target.metric) { + return; + } + qs.push(this.convertTargetToQuery(target, options, this.tsdbVersion)); + }); + + const queries = compact(qs); + + // No valid targets, return the empty result to save a round trip. + if (isEmpty(queries)) { + return of({ data: [] }); + } + + const groupByTags: any = {}; + each(queries, (query) => { + if (query.filters && query.filters.length > 0) { + each(query.filters, (val) => { + groupByTags[val.tagk] = true; + }); + } else { + each(query.tags, (val, key) => { + groupByTags[key] = true; + }); + } + }); + + options.targets = filter(options.targets, (query) => { + return query.hide !== true; + }); + + return this.performTimeSeriesQuery(queries, start, end).pipe( + catchError((err) => { + // Throw the error message here instead of the whole object to workaround the error parsing error. + throw err?.data?.error?.message || 'Error performing time series query.'; + }), + map((response) => { + const metricToTargetMapping = this.mapMetricsToTargets(response.data, options, this.tsdbVersion); + const result = _map(response.data, (metricData: any, index: number) => { + index = metricToTargetMapping[index]; + if (index === -1) { + index = 0; + } + this._saveTagKeys(metricData); + + return this.transformMetricData( + metricData, + groupByTags, + options.targets[index], + options, + this.tsdbResolution + ); + }); + return { data: result }; + }) + ); + } + + annotationQuery(options: any): Promise { + const start = this.convertToTSDBTime(options.rangeRaw.from, false, options.timezone); + const end = this.convertToTSDBTime(options.rangeRaw.to, true, options.timezone); + const qs = []; + const eventList: any[] = []; + + qs.push({ aggregator: 'sum', metric: options.annotation.target }); + + const queries = compact(qs); + + return this.performTimeSeriesQuery(queries, start, end) + .pipe( + map((results) => { + if (results.data[0]) { + let annotationObject = results.data[0].annotations; + if (options.annotation.isGlobal) { + annotationObject = results.data[0].globalAnnotations; + } + if (annotationObject) { + each(annotationObject, (annotation) => { + const event = { + text: annotation.description, + time: Math.floor(annotation.startTime) * 1000, + annotation: options.annotation, + }; + + eventList.push(event); + }); + } + } + return eventList; + }) + ) + .toPromise(); + } + + targetContainsTemplate(target: any) { + if (target.filters && target.filters.length > 0) { + for (let i = 0; i < target.filters.length; i++) { + if (this.templateSrv.variableExists(target.filters[i].filter)) { + return true; + } + } + } + + if (target.tags && Object.keys(target.tags).length > 0) { + for (const tagKey in target.tags) { + if (this.templateSrv.variableExists(target.tags[tagKey])) { + return true; + } + } + } + + return false; + } + + performTimeSeriesQuery(queries: any[], start: any, end: any): Observable { + let msResolution = false; + if (this.tsdbResolution === 2) { + msResolution = true; + } + const reqBody: any = { + start: start, + queries: queries, + msResolution: msResolution, + globalAnnotations: true, + }; + if (this.tsdbVersion === 3) { + reqBody.showQuery = true; + } + + // Relative queries (e.g. last hour) don't include an end time + if (end) { + reqBody.end = end; + } + + const options = { + method: 'POST', + url: this.url + '/api/query', + data: reqBody, + }; + + this._addCredentialOptions(options); + return getBackendSrv().fetch(options); + } + + suggestTagKeys(metric: string | number) { + return Promise.resolve(this.tagKeys[metric] || []); + } + + _saveTagKeys(metricData: { tags: {}; aggregateTags: any; metric: string | number }) { + const tagKeys = Object.keys(metricData.tags); + each(metricData.aggregateTags, (tag) => { + tagKeys.push(tag); + }); + + this.tagKeys[metricData.metric] = tagKeys; + } + + _performSuggestQuery(query: string, type: string): Observable { + return this._get('/api/suggest', { type, q: query, max: this.lookupLimit }).pipe( + map((result: any) => { + return result.data; + }) + ); + } + + _performMetricKeyValueLookup(metric: string, keys: any): Observable { + if (!metric || !keys) { + return of([]); + } + + const keysArray = keys.split(',').map((key: any) => { + return key.trim(); + }); + const key = keysArray[0]; + let keysQuery = key + '=*'; + + if (keysArray.length > 1) { + keysQuery += ',' + keysArray.splice(1).join(','); + } + + const m = metric + '{' + keysQuery + '}'; + + return this._get('/api/search/lookup', { m: m, limit: this.lookupLimit }).pipe( + map((result: any) => { + result = result.data.results; + const tagvs: any[] = []; + each(result, (r) => { + if (tagvs.indexOf(r.tags[key]) === -1) { + tagvs.push(r.tags[key]); + } + }); + return tagvs; + }) + ); + } + + _performMetricKeyLookup(metric: any): Observable { + if (!metric) { + return of([]); + } + + return this._get('/api/search/lookup', { m: metric, limit: 1000 }).pipe( + map((result: any) => { + result = result.data.results; + const tagks: any[] = []; + each(result, (r) => { + each(r.tags, (tagv, tagk) => { + if (tagks.indexOf(tagk) === -1) { + tagks.push(tagk); + } + }); + }); + return tagks; + }) + ); + } + + _get( + relativeUrl: string, + params?: { type?: string; q?: string; max?: number; m?: any; limit?: number } + ): Observable { + const options = { + method: 'GET', + url: this.url + relativeUrl, + params: params, + }; + + this._addCredentialOptions(options); + + return getBackendSrv().fetch(options); + } + + _addCredentialOptions(options: any) { + if (this.basicAuth || this.withCredentials) { + options.withCredentials = true; + } + if (this.basicAuth) { + options.headers = { Authorization: this.basicAuth }; + } + } + + metricFindQuery(query: string) { + if (!query) { + return Promise.resolve([]); + } + + let interpolated; + try { + interpolated = this.templateSrv.replace(query, {}, 'distributed'); + } catch (err) { + return Promise.reject(err); + } + + const responseTransform = (result: any) => { + return _map(result, (value) => { + return { text: value }; + }); + }; + + const metricsRegex = /metrics\((.*)\)/; + const tagNamesRegex = /tag_names\((.*)\)/; + const tagValuesRegex = /tag_values\((.*?),\s?(.*)\)/; + const tagNamesSuggestRegex = /suggest_tagk\((.*)\)/; + const tagValuesSuggestRegex = /suggest_tagv\((.*)\)/; + + const metricsQuery = interpolated.match(metricsRegex); + if (metricsQuery) { + return this._performSuggestQuery(metricsQuery[1], 'metrics').pipe(map(responseTransform)).toPromise(); + } + + const tagNamesQuery = interpolated.match(tagNamesRegex); + if (tagNamesQuery) { + return this._performMetricKeyLookup(tagNamesQuery[1]).pipe(map(responseTransform)).toPromise(); + } + + const tagValuesQuery = interpolated.match(tagValuesRegex); + if (tagValuesQuery) { + return this._performMetricKeyValueLookup(tagValuesQuery[1], tagValuesQuery[2]) + .pipe(map(responseTransform)) + .toPromise(); + } + + const tagNamesSuggestQuery = interpolated.match(tagNamesSuggestRegex); + if (tagNamesSuggestQuery) { + return this._performSuggestQuery(tagNamesSuggestQuery[1], 'tagk').pipe(map(responseTransform)).toPromise(); + } + + const tagValuesSuggestQuery = interpolated.match(tagValuesSuggestRegex); + if (tagValuesSuggestQuery) { + return this._performSuggestQuery(tagValuesSuggestQuery[1], 'tagv').pipe(map(responseTransform)).toPromise(); + } + + return Promise.resolve([]); + } + + testDatasource() { + return this._performSuggestQuery('cpu', 'metrics') + .pipe( + map(() => { + return { status: 'success', message: 'Data source is working' }; + }) + ) + .toPromise(); + } + + getAggregators() { + if (this.aggregatorsPromise) { + return this.aggregatorsPromise; + } + + this.aggregatorsPromise = this._get('/api/aggregators') + .pipe( + map((result: any) => { + if (result.data && isArray(result.data)) { + return result.data.sort(); + } + return []; + }) + ) + .toPromise(); + return this.aggregatorsPromise; + } + + getFilterTypes() { + if (this.filterTypesPromise) { + return this.filterTypesPromise; + } + + this.filterTypesPromise = this._get('/api/config/filters') + .pipe( + map((result: any) => { + if (result.data) { + return Object.keys(result.data).sort(); + } + return []; + }) + ) + .toPromise(); + return this.filterTypesPromise; + } + + transformMetricData(md: { dps: any }, groupByTags: any, target: any, options: any, tsdbResolution: number) { + const metricLabel = this.createMetricLabel(md, target, groupByTags, options); + const dps: any[] = []; + + // TSDB returns datapoints has a hash of ts => value. + // Can't use pairs(invert()) because it stringifies keys/values + each(md.dps, (v: any, k: number) => { + if (tsdbResolution === 2) { + dps.push([v, k * 1]); + } else { + dps.push([v, k * 1000]); + } + }); + + return { target: metricLabel, datapoints: dps }; + } + + createMetricLabel( + md: { dps?: any; tags?: any; metric?: any }, + target: { alias: string }, + groupByTags: any, + options: { scopedVars: any } + ) { + if (target.alias) { + const scopedVars = clone(options.scopedVars || {}); + each(md.tags, (value, key) => { + scopedVars['tag_' + key] = { value: value }; + }); + return this.templateSrv.replace(target.alias, scopedVars); + } + + let label = md.metric; + const tagData: any[] = []; + + if (!isEmpty(md.tags)) { + each(toPairs(md.tags), (tag) => { + if (has(groupByTags, tag[0])) { + tagData.push(tag[0] + '=' + tag[1]); + } + }); + } + + if (!isEmpty(tagData)) { + label += '{' + tagData.join(', ') + '}'; + } + + return label; + } + + convertTargetToQuery(target: any, options: any, tsdbVersion: number) { + if (!target.metric || target.hide) { + return null; + } + + const query: any = { + metric: this.templateSrv.replace(target.metric, options.scopedVars, 'pipe'), + aggregator: 'avg', + }; + + if (target.aggregator) { + query.aggregator = this.templateSrv.replace(target.aggregator); + } + + if (target.shouldComputeRate) { + query.rate = true; + query.rateOptions = { + counter: !!target.isCounter, + }; + + if (target.counterMax && target.counterMax.length) { + query.rateOptions.counterMax = parseInt(target.counterMax, 10); + } + + if (target.counterResetValue && target.counterResetValue.length) { + query.rateOptions.resetValue = parseInt(target.counterResetValue, 10); + } + + if (tsdbVersion >= 2) { + query.rateOptions.dropResets = + !query.rateOptions.counterMax && (!query.rateOptions.ResetValue || query.rateOptions.ResetValue === 0); + } + } + + if (!target.disableDownsampling) { + let interval = this.templateSrv.replace(target.downsampleInterval || options.interval); + + if (interval.match(/\.[0-9]+s/)) { + interval = parseFloat(interval) * 1000 + 'ms'; + } + + query.downsample = interval + '-' + target.downsampleAggregator; + + if (target.downsampleFillPolicy && target.downsampleFillPolicy !== 'none') { + query.downsample += '-' + target.downsampleFillPolicy; + } + } + + if (target.filters && target.filters.length > 0) { + query.filters = angular.copy(target.filters); + if (query.filters) { + for (const filterKey in query.filters) { + query.filters[filterKey].filter = this.templateSrv.replace( + query.filters[filterKey].filter, + options.scopedVars, + 'pipe' + ); + } + } + } else { + query.tags = angular.copy(target.tags); + if (query.tags) { + for (const tagKey in query.tags) { + query.tags[tagKey] = this.templateSrv.replace(query.tags[tagKey], options.scopedVars, 'pipe'); + } + } + } + + if (target.explicitTags) { + query.explicitTags = true; + } + + return query; + } + + mapMetricsToTargets(metrics: any, options: any, tsdbVersion: number) { + let interpolatedTagValue, arrTagV; + return _map(metrics, (metricData) => { + if (tsdbVersion === 3) { + return metricData.query.index; + } else { + return findIndex(options.targets as any[], (target) => { + if (target.filters && target.filters.length > 0) { + return target.metric === metricData.metric; + } else { + return ( + target.metric === metricData.metric && + every(target.tags, (tagV, tagK) => { + interpolatedTagValue = this.templateSrv.replace(tagV, options.scopedVars, 'pipe'); + arrTagV = interpolatedTagValue.split('|'); + return includes(arrTagV, metricData.tags[tagK]) || interpolatedTagValue === '*'; + }) + ); + } + }); + } + }); + } + + interpolateVariablesInQueries(queries: OpenTsdbQuery[], scopedVars: ScopedVars): OpenTsdbQuery[] { + if (!queries.length) { + return queries; + } + + return queries.map((query) => ({ + ...query, + metric: this.templateSrv.replace(query.metric, scopedVars), + })); + } + + convertToTSDBTime(date: any, roundUp: any, timezone: any) { + if (date === 'now') { + return null; + } + + date = dateMath.parse(date, roundUp, timezone); + return date.valueOf(); + } +} diff --git a/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png b/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png new file mode 100644 index 0000000..113f205 Binary files /dev/null and b/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png differ diff --git a/public/app/plugins/datasource/opentsdb/module.ts b/public/app/plugins/datasource/opentsdb/module.ts new file mode 100644 index 0000000..768025c --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/module.ts @@ -0,0 +1,13 @@ +import OpenTsDatasource from './datasource'; +import { OpenTsQueryCtrl } from './query_ctrl'; +import { DataSourcePlugin } from '@grafana/data'; +import { ConfigEditor } from './components/ConfigEditor'; + +class AnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; +} + +export const plugin = new DataSourcePlugin(OpenTsDatasource) + .setQueryCtrl(OpenTsQueryCtrl) + .setConfigEditor(ConfigEditor) + .setAnnotationQueryCtrl(AnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html b/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html new file mode 100644 index 0000000..9146720 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html @@ -0,0 +1,7 @@ +
    +
    + OpenTSDB metrics query + +
    + +
    diff --git a/public/app/plugins/datasource/opentsdb/partials/query.editor.html b/public/app/plugins/datasource/opentsdb/partials/query.editor.html new file mode 100644 index 0000000..635acd3 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/partials/query.editor.html @@ -0,0 +1,262 @@ + +
    +
    + + + +
    +
    + +
    + +
    +
    +
    + + +
    + +
    +
    +
    +
    + +
    +
    + + + + blank for auto, or for example 1m + +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + + + + +
    +
    +
    +
    + +
    +
    + + + +
    + {{fil.tagk}} = {{fil.type}}({{fil.filter}}) , groupBy = {{fil.groupBy}} + + + + + + +
    + +
    + +
    +
    + + +
    + +
    + +
    + +
    +
    + +
    + + +
    + + + + +
    + + +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + + + + + + + + +
    + +
    +
    +
    +
    + +
    + + + + + + + +
    + + + + + + + +
    + +
    + + +
    + +
    +
    +
    +
    +
    + diff --git a/public/app/plugins/datasource/opentsdb/plugin.json b/public/app/plugins/datasource/opentsdb/plugin.json new file mode 100644 index 0000000..12e9d1e --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/plugin.json @@ -0,0 +1,23 @@ +{ + "type": "datasource", + "name": "OpenTSDB", + "id": "opentsdb", + "category": "tsdb", + + "metrics": true, + "defaultMatchFormat": "pipe", + "annotations": true, + "alerting": true, + + "info": { + "description": "Open source time series database", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/opentsdb_logo.png", + "large": "img/opentsdb_logo.png" + } + } +} diff --git a/public/app/plugins/datasource/opentsdb/query_ctrl.ts b/public/app/plugins/datasource/opentsdb/query_ctrl.ts new file mode 100644 index 0000000..be0d789 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/query_ctrl.ts @@ -0,0 +1,222 @@ +import { map, size, has } from 'lodash'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { auto } from 'angular'; +import { textUtil, rangeUtil } from '@grafana/data'; + +export class OpenTsQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + aggregators: any; + fillPolicies: any; + filterTypes: any; + tsdbVersion: any; + aggregator: any; + downsampleInterval: any; + downsampleAggregator: any; + downsampleFillPolicy: any; + errors: any; + suggestMetrics: any; + suggestTagKeys: any; + suggestTagValues: any; + addTagMode = false; + addFilterMode = false; + + /** @ngInject */ + constructor($scope: any, $injector: auto.IInjectorService) { + super($scope, $injector); + + this.errors = this.validateTarget(); + this.aggregators = ['avg', 'sum', 'min', 'max', 'dev', 'zimsum', 'mimmin', 'mimmax']; + this.fillPolicies = ['none', 'nan', 'null', 'zero']; + this.filterTypes = [ + 'wildcard', + 'iliteral_or', + 'not_iliteral_or', + 'not_literal_or', + 'iwildcard', + 'literal_or', + 'regexp', + ]; + + this.tsdbVersion = this.datasource.tsdbVersion; + + if (!this.target.aggregator) { + this.target.aggregator = 'sum'; + } + + if (!this.target.downsampleAggregator) { + this.target.downsampleAggregator = 'avg'; + } + + if (!this.target.downsampleFillPolicy) { + this.target.downsampleFillPolicy = 'none'; + } + + this.datasource.getAggregators().then((aggs: { length: number }) => { + if (aggs.length !== 0) { + this.aggregators = aggs; + } + }); + + this.datasource.getFilterTypes().then((filterTypes: { length: number }) => { + if (filterTypes.length !== 0) { + this.filterTypes = filterTypes; + } + }); + + // needs to be defined here as it is called from typeahead + this.suggestMetrics = (query: string, callback: any) => { + this.datasource + .metricFindQuery('metrics(' + query + ')') + .then(this.getTextValues) + .then(callback); + }; + + this.suggestTagKeys = (query: any, callback: any) => { + this.datasource.suggestTagKeys(this.target.metric).then(callback); + }; + + this.suggestTagValues = (query: string, callback: any) => { + this.datasource + .metricFindQuery('suggest_tagv(' + query + ')') + .then(this.getTextValues) + .then(callback); + }; + } + + targetBlur() { + this.errors = this.validateTarget(); + this.refresh(); + } + + getTextValues(metricFindResult: any) { + return map(metricFindResult, (value) => { + return textUtil.escapeHtml(value.text); + }); + } + + addTag() { + if (this.target.filters && this.target.filters.length > 0) { + this.errors.tags = 'Please remove filters to use tags, tags and filters are mutually exclusive.'; + } + + if (!this.addTagMode) { + this.addTagMode = true; + return; + } + + if (!this.target.tags) { + this.target.tags = {}; + } + + this.errors = this.validateTarget(); + + if (!this.errors.tags) { + this.target.tags[this.target.currentTagKey] = this.target.currentTagValue; + this.target.currentTagKey = ''; + this.target.currentTagValue = ''; + this.targetBlur(); + } + + this.addTagMode = false; + } + + removeTag(key: string | number) { + delete this.target.tags[key]; + this.targetBlur(); + } + + editTag(key: string | number, value: any) { + this.removeTag(key); + this.target.currentTagKey = key; + this.target.currentTagValue = value; + this.addTag(); + } + + closeAddTagMode() { + this.addTagMode = false; + return; + } + + addFilter() { + if (this.target.tags && size(this.target.tags) > 0) { + this.errors.filters = 'Please remove tags to use filters, tags and filters are mutually exclusive.'; + } + + if (!this.addFilterMode) { + this.addFilterMode = true; + return; + } + + if (!this.target.filters) { + this.target.filters = []; + } + + if (!this.target.currentFilterType) { + this.target.currentFilterType = 'iliteral_or'; + } + + if (!this.target.currentFilterGroupBy) { + this.target.currentFilterGroupBy = false; + } + + this.errors = this.validateTarget(); + + if (!this.errors.filters) { + const currentFilter = { + type: this.target.currentFilterType, + tagk: this.target.currentFilterKey, + filter: this.target.currentFilterValue, + groupBy: this.target.currentFilterGroupBy, + }; + this.target.filters.push(currentFilter); + this.target.currentFilterType = 'literal_or'; + this.target.currentFilterKey = ''; + this.target.currentFilterValue = ''; + this.target.currentFilterGroupBy = false; + this.targetBlur(); + } + + this.addFilterMode = false; + } + + removeFilter(index: number) { + this.target.filters.splice(index, 1); + this.targetBlur(); + } + + editFilter(fil: { tagk: any; filter: any; type: any; groupBy: any }, index: number) { + this.removeFilter(index); + this.target.currentFilterKey = fil.tagk; + this.target.currentFilterValue = fil.filter; + this.target.currentFilterType = fil.type; + this.target.currentFilterGroupBy = fil.groupBy; + this.addFilter(); + } + + closeAddFilterMode() { + this.addFilterMode = false; + return; + } + + validateTarget() { + const errs: any = {}; + + if (this.target.shouldDownsample) { + try { + if (this.target.downsampleInterval) { + rangeUtil.describeInterval(this.target.downsampleInterval); + } else { + errs.downsampleInterval = "You must supply a downsample interval (e.g. '1m' or '1h')."; + } + } catch (err) { + errs.downsampleInterval = err.message; + } + } + + if (this.target.tags && has(this.target.tags, this.target.currentTagKey)) { + errs.tags = "Duplicate tag key '" + this.target.currentTagKey + "'."; + } + + return errs; + } +} diff --git a/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts b/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts new file mode 100644 index 0000000..a7b05d1 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts @@ -0,0 +1,141 @@ +import OpenTsDatasource from '../datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { OpenTsdbQuery } from '../types'; +import { createFetchResponse } from '../../../../../test/helpers/createFetchResponse'; +import { of } from 'rxjs'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +const metricFindQueryData = [ + { + target: 'prod1.count', + datapoints: [ + [10, 1], + [12, 1], + ], + }, +]; + +describe('opentsdb', () => { + function getTestcontext({ data = metricFindQueryData }: { data?: any } = {}) { + jest.clearAllMocks(); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + fetchMock.mockImplementation(() => of(createFetchResponse(data))); + + const instanceSettings = { url: '', jsonData: { tsdbVersion: 1 } }; + const replace = jest.fn((value) => value); + const templateSrv: any = { + replace, + }; + + const ds = new OpenTsDatasource(instanceSettings, templateSrv); + + return { ds, templateSrv, fetchMock }; + } + + describe('When performing metricFindQuery', () => { + it('metrics() should generate api suggest query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('metrics(pew)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/suggest'); + expect(fetchMock.mock.calls[0][0].params?.type).toBe('metrics'); + expect(fetchMock.mock.calls[0][0].params?.q).toBe('pew'); + expect(results).not.toBe(null); + }); + + it('tag_names(cpu) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_names(cpu)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu'); + expect(results).not.toBe(null); + }); + + it('tag_values(cpu, test) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_values(cpu, hostname)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu{hostname=*}'); + expect(results).not.toBe(null); + }); + + it('tag_values(cpu, test) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_values(cpu, hostname, env=$env)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu{hostname=*,env=$env}'); + expect(results).not.toBe(null); + }); + + it('tag_values(cpu, test) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_values(cpu, hostname, env=$env, region=$region)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu{hostname=*,env=$env,region=$region}'); + expect(results).not.toBe(null); + }); + + it('suggest_tagk() should generate api suggest query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('suggest_tagk(foo)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/suggest'); + expect(fetchMock.mock.calls[0][0].params?.type).toBe('tagk'); + expect(fetchMock.mock.calls[0][0].params?.q).toBe('foo'); + expect(results).not.toBe(null); + }); + + it('suggest_tagv() should generate api suggest query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('suggest_tagv(bar)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/suggest'); + expect(fetchMock.mock.calls[0][0].params?.type).toBe('tagv'); + expect(fetchMock.mock.calls[0][0].params?.q).toBe('bar'); + expect(results).not.toBe(null); + }); + }); + + describe('When interpolating variables', () => { + it('should return an empty array if no queries are provided', () => { + const { ds } = getTestcontext(); + expect(ds.interpolateVariablesInQueries([], {})).toHaveLength(0); + }); + + it('should replace correct variables', () => { + const { ds, templateSrv } = getTestcontext(); + const variableName = 'someVar'; + const logQuery: OpenTsdbQuery = { + refId: 'someRefId', + metric: `$${variableName}`, + }; + + ds.interpolateVariablesInQueries([logQuery], {}); + + expect(templateSrv.replace).toHaveBeenCalledWith('$someVar', {}); + expect(templateSrv.replace).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts b/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts new file mode 100644 index 0000000..e574470 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts @@ -0,0 +1,93 @@ +import { OpenTsQueryCtrl } from '../query_ctrl'; + +describe('OpenTsQueryCtrl', () => { + const ctx = { + target: { target: '' }, + datasource: { + tsdbVersion: '', + getAggregators: () => Promise.resolve([]), + getFilterTypes: () => Promise.resolve([]), + }, + } as any; + + ctx.panelCtrl = { + panel: { + targets: [ctx.target], + }, + refresh: () => {}, + }; + + OpenTsQueryCtrl.prototype = Object.assign(OpenTsQueryCtrl.prototype, ctx); + + beforeEach(() => { + ctx.ctrl = new OpenTsQueryCtrl({}, {} as any); + }); + + describe('init query_ctrl variables', () => { + it('filter types should be initialized', () => { + expect(ctx.ctrl.filterTypes.length).toBe(7); + }); + + it('aggregators should be initialized', () => { + expect(ctx.ctrl.aggregators.length).toBe(8); + }); + + it('fill policy options should be initialized', () => { + expect(ctx.ctrl.fillPolicies.length).toBe(4); + }); + }); + + describe('when adding filters and tags', () => { + it('addTagMode should be false when closed', () => { + ctx.ctrl.addTagMode = true; + ctx.ctrl.closeAddTagMode(); + expect(ctx.ctrl.addTagMode).toBe(false); + }); + + it('addFilterMode should be false when closed', () => { + ctx.ctrl.addFilterMode = true; + ctx.ctrl.closeAddFilterMode(); + expect(ctx.ctrl.addFilterMode).toBe(false); + }); + + it('removing a tag from the tags list', () => { + ctx.ctrl.target.tags = { tagk: 'tag_key', tagk2: 'tag_value2' }; + ctx.ctrl.removeTag('tagk'); + expect(Object.keys(ctx.ctrl.target.tags).length).toBe(1); + }); + + it('removing a filter from the filters list', () => { + ctx.ctrl.target.filters = [ + { + tagk: 'tag_key', + filter: 'tag_value2', + type: 'wildcard', + groupBy: true, + }, + ]; + ctx.ctrl.removeFilter(0); + expect(ctx.ctrl.target.filters.length).toBe(0); + }); + + it('adding a filter when tags exist should generate error', () => { + ctx.ctrl.target.tags = { tagk: 'tag_key', tagk2: 'tag_value2' }; + ctx.ctrl.addFilter(); + expect(ctx.ctrl.errors.filters).toBe( + 'Please remove tags to use filters, tags and filters are mutually exclusive.' + ); + }); + + it('adding a tag when filters exist should generate error', () => { + ctx.ctrl.target.filters = [ + { + tagk: 'tag_key', + filter: 'tag_value2', + type: 'wildcard', + groupBy: true, + }, + ]; + ctx.ctrl.addTag(); + expect(ctx.ctrl.errors.tags).toBe('Please remove filters to use tags, tags and filters are mutually exclusive.'); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/types.ts b/public/app/plugins/datasource/opentsdb/types.ts new file mode 100644 index 0000000..4a5cf58 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/types.ts @@ -0,0 +1,11 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; + +export interface OpenTsdbQuery extends DataQuery { + metric?: any; +} + +export interface OpenTsdbOptions extends DataSourceJsonData { + tsdbVersion: number; + tsdbResolution: number; + lookupLimit: number; +} diff --git a/public/app/plugins/datasource/postgres/README.md b/public/app/plugins/datasource/postgres/README.md new file mode 100644 index 0000000..75c3615 --- /dev/null +++ b/public/app/plugins/datasource/postgres/README.md @@ -0,0 +1,12 @@ +# Grafana PostgreSQL Data Source - Native Plugin + +Grafana ships with a built-in PostgreSQL data source plugin that allows you to query and visualize data from a PostgreSQL compatible database. + +## Adding the data source + +1. Open the side menu by clicking the Grafana icon in the top header. +2. In the side menu under the Dashboards link you should find a link named Data Sources. +3. Click the + Add data source button in the top header. +4. Select PostgreSQL from the Type dropdown. + +[http://docs.grafana.org/features/datasources/postgres/](http://docs.grafana.org/features/datasources/postgres/) diff --git a/public/app/plugins/datasource/postgres/config_ctrl.ts b/public/app/plugins/datasource/postgres/config_ctrl.ts new file mode 100644 index 0000000..c70d2fc --- /dev/null +++ b/public/app/plugins/datasource/postgres/config_ctrl.ts @@ -0,0 +1,92 @@ +import { find } from 'lodash'; +import { + createChangeHandler, + createResetHandler, + PasswordFieldEnum, +} from '../../../features/datasources/utils/passwordHandlers'; +import DatasourceSrv from 'app/features/plugins/datasource_srv'; + +export class PostgresConfigCtrl { + static templateUrl = 'partials/config.html'; + + // Set through angular bindings + declare current: any; + + datasourceSrv: any; + showTimescaleDBHelp: boolean; + onPasswordReset: ReturnType; + onPasswordChange: ReturnType; + + /** @ngInject */ + constructor($scope: any, datasourceSrv: DatasourceSrv) { + this.current = $scope.ctrl.current; + this.datasourceSrv = datasourceSrv; + this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full'; + this.current.jsonData.tlsConfigurationMethod = this.current.jsonData.tlsConfigurationMethod || 'file-path'; + this.current.jsonData.postgresVersion = this.current.jsonData.postgresVersion || 903; + this.showTimescaleDBHelp = false; + this.autoDetectFeatures(); + this.onPasswordReset = createResetHandler(this, PasswordFieldEnum.Password); + this.onPasswordChange = createChangeHandler(this, PasswordFieldEnum.Password); + this.tlsModeMapping(); + } + + autoDetectFeatures() { + if (!this.current.id) { + return; + } + + this.datasourceSrv.loadDatasource(this.current.name).then((ds: any) => { + return ds.getVersion().then((version: any) => { + version = Number(version[0].text); + + // timescaledb is only available for 9.6+ + if (version >= 906) { + ds.getTimescaleDBVersion().then((version: any) => { + if (version.length === 1) { + this.current.jsonData.timescaledb = true; + } + }); + } + + const major = Math.trunc(version / 100); + const minor = version % 100; + let name = String(major); + if (version < 1000) { + name = String(major) + '.' + String(minor); + } + if (!find(this.postgresVersions, (p: any) => p.value === version)) { + this.postgresVersions.push({ name: name, value: version }); + } + this.current.jsonData.postgresVersion = version; + }); + }); + } + + toggleTimescaleDBHelp() { + this.showTimescaleDBHelp = !this.showTimescaleDBHelp; + } + + tlsModeMapping() { + if (this.current.jsonData.sslmode === 'disable') { + this.current.jsonData.tlsAuth = false; + this.current.jsonData.tlsAuthWithCACert = false; + this.current.jsonData.tlsSkipVerify = true; + } else { + this.current.jsonData.tlsAuth = true; + this.current.jsonData.tlsAuthWithCACert = true; + this.current.jsonData.tlsSkipVerify = false; + } + } + + // the value portion is derived from postgres server_version_num/100 + postgresVersions = [ + { name: '9.3', value: 903 }, + { name: '9.4', value: 904 }, + { name: '9.5', value: 905 }, + { name: '9.6', value: 906 }, + { name: '10', value: 1000 }, + { name: '11', value: 1100 }, + { name: '12', value: 1200 }, + ]; +} diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts new file mode 100644 index 0000000..3c55102 --- /dev/null +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -0,0 +1,200 @@ +import { map as _map } from 'lodash'; +import { map } from 'rxjs/operators'; +import { BackendDataSourceResponse, DataSourceWithBackend, FetchResponse, getBackendSrv } from '@grafana/runtime'; +import { AnnotationEvent, DataSourceInstanceSettings, MetricFindValue, ScopedVars } from '@grafana/data'; + +import ResponseParser from './response_parser'; +import PostgresQueryModel from 'app/plugins/datasource/postgres/postgres_query_model'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +//Types +import { PostgresOptions, PostgresQuery, PostgresQueryForInterpolation } from './types'; +import { getSearchFilterScopedVar } from '../../../features/variables/utils'; + +export class PostgresDatasource extends DataSourceWithBackend { + id: any; + name: any; + jsonData: any; + responseParser: ResponseParser; + queryModel: PostgresQueryModel; + interval: string; + + constructor( + instanceSettings: DataSourceInstanceSettings, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(instanceSettings); + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.jsonData = instanceSettings.jsonData; + this.responseParser = new ResponseParser(); + this.queryModel = new PostgresQueryModel({}); + const settingsData = instanceSettings.jsonData || ({} as PostgresOptions); + this.interval = settingsData.timeInterval || '1m'; + } + + interpolateVariable = (value: string | string[], variable: { multi: any; includeAll: any }) => { + if (typeof value === 'string') { + if (variable.multi || variable.includeAll) { + return this.queryModel.quoteLiteral(value); + } else { + return value; + } + } + + if (typeof value === 'number') { + return value; + } + + const quotedValues = _map(value, (v) => { + return this.queryModel.quoteLiteral(v); + }); + return quotedValues.join(','); + }; + + interpolateVariablesInQueries( + queries: PostgresQueryForInterpolation[], + scopedVars: ScopedVars + ): PostgresQueryForInterpolation[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable), + rawQuery: true, + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + filterQuery(query: PostgresQuery): boolean { + return !query.hide; + } + + applyTemplateVariables(target: PostgresQuery, scopedVars: ScopedVars): Record { + const queryModel = new PostgresQueryModel(target, this.templateSrv, scopedVars); + return { + refId: target.refId, + datasourceId: this.id, + rawSql: queryModel.render(this.interpolateVariable as any), + format: target.format, + }; + } + + async annotationQuery(options: any): Promise { + if (!options.annotation.rawQuery) { + return Promise.reject({ + message: 'Query missing in annotation definition', + }); + } + + const query = { + refId: options.annotation.name, + datasourceId: this.id, + rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), + format: 'table', + }; + + return getBackendSrv() + .fetch({ + url: '/api/ds/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [query], + }, + requestId: options.annotation.name, + }) + .pipe( + map( + async (res: FetchResponse) => + await this.responseParser.transformAnnotationResponse(options, res.data) + ) + ) + .toPromise(); + } + + metricFindQuery(query: string, optionalOptions: any): Promise { + let refId = 'tempvar'; + if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { + refId = optionalOptions.variable.name; + } + + const rawSql = this.templateSrv.replace( + query, + getSearchFilterScopedVar({ query, wildcardChar: '%', options: optionalOptions }), + this.interpolateVariable + ); + + const interpolatedQuery = { + refId: refId, + datasourceId: this.id, + rawSql, + format: 'table', + }; + + const range = this.timeSrv.timeRange(); + + return getBackendSrv() + .fetch({ + url: '/api/ds/query', + method: 'POST', + data: { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + queries: [interpolatedQuery], + }, + requestId: refId, + }) + .pipe( + map((rsp) => { + return this.responseParser.transformMetricFindResponse(rsp); + }) + ) + .toPromise(); + } + + getVersion(): Promise { + return this.metricFindQuery("SELECT current_setting('server_version_num')::int/100", {}); + } + + getTimescaleDBVersion(): Promise { + return this.metricFindQuery("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'", {}); + } + + testDatasource(): Promise { + return this.metricFindQuery('SELECT 1', {}) + .then(() => { + return { status: 'success', message: 'Database Connection OK' }; + }) + .catch((err: any) => { + console.error(err); + if (err.data && err.data.message) { + return { status: 'error', message: err.data.message }; + } else { + return { status: 'error', message: err.status }; + } + }); + } + + targetContainsTemplate(target: any) { + let rawSql = ''; + + if (target.rawQuery) { + rawSql = target.rawSql; + } else { + const query = new PostgresQueryModel(target); + rawSql = query.buildQuery(); + } + + rawSql = rawSql.replace('$__', ''); + + return this.templateSrv.variableExists(rawSql); + } +} diff --git a/public/app/plugins/datasource/postgres/img/postgresql_logo.svg b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg new file mode 100644 index 0000000..e79f8bf --- /dev/null +++ b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/postgres/meta_query.ts b/public/app/plugins/datasource/postgres/meta_query.ts new file mode 100644 index 0000000..c30ab26 --- /dev/null +++ b/public/app/plugins/datasource/postgres/meta_query.ts @@ -0,0 +1,168 @@ +import QueryModel from './postgres_query_model'; + +export class PostgresMetaQuery { + constructor(private target: { table: string; timeColumn: string }, private queryModel: QueryModel) {} + + getOperators(datatype: string) { + switch (datatype) { + case 'float4': + case 'float8': { + return ['=', '!=', '<', '<=', '>', '>=']; + } + case 'text': + case 'varchar': + case 'char': { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '~', '~*', '!~', '!~*']; + } + default: { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN']; + } + } + } + + // quote identifier as literal to use in metadata queries + quoteIdentAsLiteral(value: string) { + return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value)); + } + + findMetricTable() { + // query that returns first table found that has a timestamp(tz) column and a float column + let query = ` +SELECT + quote_ident(table_name) as table_name, + ( SELECT + quote_ident(column_name) as column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name IN ('timestamptz','timestamp') + ORDER BY ordinal_position LIMIT 1 + ) AS time_column, + ( SELECT + quote_ident(column_name) AS column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name='float8' + ORDER BY ordinal_position LIMIT 1 + ) AS value_column +FROM information_schema.tables t +WHERE `; + query += this.buildSchemaConstraint(); + query += ` AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name IN ('timestamptz','timestamp') + ) AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name='float8' + ) +LIMIT 1 +;`; + return query; + } + + buildSchemaConstraint() { + const query = ` +table_schema IN ( + SELECT + CASE WHEN trim(s[i]) = '"$user"' THEN user ELSE trim(s[i]) END + FROM + generate_series( + array_lower(string_to_array(current_setting('search_path'),','),1), + array_upper(string_to_array(current_setting('search_path'),','),1) + ) as i, + string_to_array(current_setting('search_path'),',') s +)`; + return query; + } + + buildTableConstraint(table: string) { + let query = ''; + + // check for schema qualified table + if (table.includes('.')) { + const parts = table.split('.'); + query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]); + query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]); + return query; + } else { + query = this.buildSchemaConstraint(); + query += ' AND table_name = ' + this.quoteIdentAsLiteral(table); + + return query; + } + } + + buildTableQuery() { + let query = 'SELECT quote_ident(table_name) FROM information_schema.tables WHERE '; + query += this.buildSchemaConstraint(); + query += ' ORDER BY table_name'; + return query; + } + + buildColumnQuery(type?: string) { + let query = 'SELECT quote_ident(column_name) FROM information_schema.columns WHERE '; + query += this.buildTableConstraint(this.target.table); + + switch (type) { + case 'time': { + query += + " AND data_type IN ('timestamp without time zone','timestamp with time zone','bigint','integer','double precision','real')"; + break; + } + case 'metric': { + query += " AND data_type IN ('text','character','character varying')"; + break; + } + case 'value': { + query += " AND data_type IN ('bigint','integer','double precision','real')"; + query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn); + break; + } + case 'group': { + query += " AND data_type IN ('text','character','character varying')"; + break; + } + } + + query += ' ORDER BY column_name'; + + return query; + } + + buildValueQuery(column: string) { + let query = 'SELECT DISTINCT quote_literal(' + column + ')'; + query += ' FROM ' + this.target.table; + query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')'; + query += ' AND ' + column + ' IS NOT NULL'; + query += ' ORDER BY 1 LIMIT 100'; + return query; + } + + buildDatatypeQuery(column: string) { + let query = 'SELECT udt_name FROM information_schema.columns WHERE '; + query += this.buildTableConstraint(this.target.table); + query += ' AND column_name = ' + this.quoteIdentAsLiteral(column); + return query; + } + + buildAggregateQuery() { + let query = 'SELECT DISTINCT proname FROM pg_aggregate '; + query += 'INNER JOIN pg_proc ON pg_aggregate.aggfnoid = pg_proc.oid '; + query += 'INNER JOIN pg_type ON pg_type.oid=pg_proc.prorettype '; + query += "WHERE pronargs=1 AND typname IN ('float8') AND aggkind='n' ORDER BY 1"; + return query; + } +} diff --git a/public/app/plugins/datasource/postgres/mode-sql.js b/public/app/plugins/datasource/postgres/mode-sql.js new file mode 100644 index 0000000..2a6d897 --- /dev/null +++ b/public/app/plugins/datasource/postgres/mode-sql.js @@ -0,0 +1,114 @@ +ace.define( + 'ace/mode/sql_highlight_rules', + ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'], + function (require, exports, module) { + 'use strict'; + + var oop = require('../lib/oop'); + var TextHighlightRules = require('./text_highlight_rules').TextHighlightRules; + + var SqlHighlightRules = function () { + var keywords = + 'select|insert|update|delete|from|where|and|or|group|by|order|limit|offset|having|as|case|' + + 'when|else|end|type|left|right|join|on|outer|desc|asc|union|create|table|primary|key|if|' + + 'foreign|not|references|default|null|inner|cross|natural|database|drop|grant'; + + var builtinConstants = 'true|false'; + + var builtinFunctions = + 'avg|count|first|last|max|min|sum|upper|lower|substring|char_length|round|rank|now|' + 'coalesce'; + + var dataTypes = + 'int|int2|int4|int8|numeric|decimal|date|varchar|char|bigint|float|bool|bytea|text|timestamp|' + + 'time|money|real|integer'; + + var keywordMapper = this.createKeywordMapper( + { + 'support.function': builtinFunctions, + keyword: keywords, + 'constant.language': builtinConstants, + 'storage.type': dataTypes, + }, + 'identifier', + true + ); + + this.$rules = { + start: [ + { + token: 'comment', + regex: '--.*$', + }, + { + token: 'comment', + start: '/\\*', + end: '\\*/', + }, + { + token: 'string', // " string + regex: '".*?"', + }, + { + token: 'string', // ' string + regex: "'.*?'", + }, + { + token: 'constant.numeric', // float + regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', + }, + { + token: keywordMapper, + regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', + }, + { + token: 'keyword.operator', + regex: '\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|=', + }, + { + token: 'paren.lparen', + regex: '[\\(]', + }, + { + token: 'paren.rparen', + regex: '[\\)]', + }, + { + token: 'text', + regex: '\\s+', + }, + ], + }; + this.normalizeRules(); + }; + + oop.inherits(SqlHighlightRules, TextHighlightRules); + + exports.SqlHighlightRules = SqlHighlightRules; + } +); + +ace.define( + 'ace/mode/sql', + ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/sql_highlight_rules'], + function (require, exports, module) { + 'use strict'; + + var oop = require('../lib/oop'); + var TextMode = require('./text').Mode; + var SqlHighlightRules = require('./sql_highlight_rules').SqlHighlightRules; + + var Mode = function () { + this.HighlightRules = SqlHighlightRules; + this.$behaviour = this.$defaultBehaviour; + }; + oop.inherits(Mode, TextMode); + + (function () { + this.lineCommentStart = '--'; + + this.$id = 'ace/mode/sql'; + }.call(Mode.prototype)); + + exports.Mode = Mode; + } +); diff --git a/public/app/plugins/datasource/postgres/module.ts b/public/app/plugins/datasource/postgres/module.ts new file mode 100644 index 0000000..52ddcfb --- /dev/null +++ b/public/app/plugins/datasource/postgres/module.ts @@ -0,0 +1,32 @@ +import { PostgresDatasource } from './datasource'; +import { PostgresQueryCtrl } from './query_ctrl'; +import { PostgresConfigCtrl } from './config_ctrl'; +import { PostgresQuery } from './types'; +import { DataSourcePlugin } from '@grafana/data'; + +const defaultQuery = `SELECT + extract(epoch from time_column) AS time, + text_column as text, + tags_column as tags +FROM + metric_table +WHERE + $__timeFilter(time_column) +`; + +class PostgresAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + + declare annotation: any; + + /** @ngInject */ + constructor($scope: any) { + this.annotation = $scope.ctrl.annotation; + this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; + } +} + +export const plugin = new DataSourcePlugin(PostgresDatasource) + .setQueryCtrl(PostgresQueryCtrl) + .setConfigCtrl(PostgresConfigCtrl) + .setAnnotationQueryCtrl(PostgresAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/postgres/partials/annotations.editor.html b/public/app/plugins/datasource/postgres/partials/annotations.editor.html new file mode 100644 index 0000000..bc2a209 --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/annotations.editor.html @@ -0,0 +1,56 @@ +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    Annotation Query Format
    +An annotation is an event that is overlaid on top of graphs. The query can have up to four columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time for the annotation event time. Use epoch time or any native date data type. +- column with alias: timeend for the annotation event time-end. Use epoch time or any native date data type. +- column with alias: text for the annotation text +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2' + + +Macros: +- $__time(column) -> column as "time" +- $__timeEpoch -> extract(epoch from column) as "time" +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 +- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> '2017-04-21T05:01:17Z' +- $__timeTo() -> '2017-04-21T05:01:17Z' +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +- $__unixEpochNanoFrom() -> 1494410783152415214 +- $__unixEpochNanoTo() -> 1494497183142514872 +
    +
    +
    +
    diff --git a/public/app/plugins/datasource/postgres/partials/config.html b/public/app/plugins/datasource/postgres/partials/config.html new file mode 100644 index 0000000..9bff000 --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/config.html @@ -0,0 +1,195 @@ + +

    PostgreSQL Connection

    + +
    +
    + Host + +
    + +
    + Database + +
    + +
    +
    + User + +
    +
    + +
    +
    + +
    + +
    + + + This option determines whether or with what priority a secure TLS/SSL TCP/IP connection will be negotiated with the server. + +
    +
    + +
    + +
    + + + This option determines how TLS/SSL certifications are configured. Selecting File system path will allow + you to configure certificates by specifying paths to existing certificates on the local file system where + Grafana is running. Be sure that the file is readable by the user executing the Grafana process.

    + + Selecting Certificate content will allow you to configure certificates by specifying its content. + The content will be stored encrypted in Grafana's database. When connecting to the database the certificates + will be written as files to Grafana's configured data path on the local file system. +
    +
    +
    +
    + +
    +
    +
    TLS/SSL Auth Details
    +
    +
    + TLS/SSL Root Certificate + + + If the selected TLS/SSL mode requires a server root certificate, provide the path to the file here. + +
    +
    + TLS/SSL Client Certificate + + + To authenticate with an TLS/SSL client certificate, provide the path to the file here. + Be sure that the file is readable by the user executing the grafana process. + +
    +
    + TLS/SSL Client Key + + + To authenticate with a client TLS/SSL certificate, provide the path to the corresponding key file here. + Be sure that the file is only readable by the user executing the grafana process. + +
    +
    + + + +Connection limits + +
    +
    + Max open + + + The maximum number of open connections to the database. If Max idle connections is greater than 0 and the + Max open connections is less than Max idle connections, then Max idle connections will be + reduced to match the Max open connections limit. If set to 0, there is no limit on the number of open + connections. + +
    +
    + Max idle + + + The maximum number of connections in the idle connection pool. If Max open connections is greater than 0 but + less than the Max idle connections, then the Max idle connections will be reduced to match the + Max open connections limit. If set to 0, no idle connections are retained. + +
    +
    + Max lifetime + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever. + +
    +
    + +

    PostgreSQL details

    + +
    +
    + + Version + + This option controls what functions are available in the PostgreSQL query builder. + + + + + +
    +
    + + +
    + +
    +
    + Min time interval + + + A lower limit for the auto group by time interval. Recommended to be set to write frequency, + for example 1m if your data is written every minute. + +
    +
    +
    +
    +

    + TimescaleDB is a + time-series database built as a PostgreSQL extension. If enabled, Grafana will use time_bucket in + the $__timeGroup macro and display TimescaleDB specific aggregate functions in the query builder. +

    +
    +
    +
    + +
    +
    +
    User Permission
    +

    + The database user should only be granted SELECT permissions on the specified database & tables you want to query. + Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements + like DELETE FROM user; and DROP TABLE user; would be executed. To protect against this we + Highly recommmend you create a specific PostgreSQL user with restricted permissions. +

    +
    +
    diff --git a/public/app/plugins/datasource/postgres/partials/query.editor.html b/public/app/plugins/datasource/postgres/partials/query.editor.html new file mode 100644 index 0000000..13bba6a --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/query.editor.html @@ -0,0 +1,191 @@ + + +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    + + + + + + + + +
    + +
    +
    +
    + +
    + +
    +
    + +
    + +
    + + +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    + +
    + + +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    + + + + +
    + +
    + +
    + +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    Time series:
    +- return column named time (UTC in seconds or timestamp)
    +- return column(s) with numeric datatype as values
    +Optional:
    +  - return column named metric to represent the series name.
    +  - If multiple value columns are returned the metric column is used as prefix.
    +  - If no column named metric is found the column name of the value column is used as series name
    +
    +Resultsets of time series queries need to be sorted by time.
    +
    +Table:
    +- return any set of columns
    +
    +Macros:
    +- $__time(column) -> column as "time"
    +- $__timeEpoch -> extract(epoch from column) as "time"
    +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z'
    +- $__unixEpochFilter(column) ->  column >= 1492750877 AND column <= 1492750877
    +- $__unixEpochNanoFilter(column) ->  column >= 1494410783152415214 AND column <= 1494497183142514872
    +- $__timeGroup(column,'5m'[, fillvalue]) -> (extract(epoch from column)/300)::bigint*300
    +     by setting fillvalue grafana will fill in missing values according to the interval
    +     fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet
    +- $__timeGroupAlias(column,'5m') -> (extract(epoch from column)/300)::bigint*300 AS "time"
    +- $__unixEpochGroup(column,'5m') -> floor(column/300)*300
    +- $__unixEpochGroupAlias(column,'5m') -> floor(column/300)*300 AS "time"
    +
    +Example of group by and order by with $__timeGroup:
    +SELECT
    +  $__timeGroup(date_time_col, '1h'),
    +  sum(value) as value
    +FROM yourtable
    +GROUP BY time
    +ORDER BY time
    +
    +Or build your own conditionals using these macros which just return the values:
    +- $__timeFrom() ->  '2017-04-21T05:01:17Z'
    +- $__timeTo() ->  '2017-04-21T05:01:17Z'
    +- $__unixEpochFrom() ->  1492750877
    +- $__unixEpochTo() ->  1492750877
    +- $__unixEpochNanoFrom() ->  1494410783152415214
    +- $__unixEpochNanoTo() ->  1494497183142514872
    +    
    +
    + + + +
    +
    {{ctrl.lastQueryMeta.executedQueryString}}
    +
    + +
    +
    {{ctrl.lastQueryError}}
    +
    + +
    diff --git a/public/app/plugins/datasource/postgres/plugin.json b/public/app/plugins/datasource/postgres/plugin.json new file mode 100644 index 0000000..815356e --- /dev/null +++ b/public/app/plugins/datasource/postgres/plugin.json @@ -0,0 +1,26 @@ +{ + "type": "datasource", + "name": "PostgreSQL", + "id": "postgres", + "category": "sql", + + "info": { + "description": "Data source for PostgreSQL and compatible databases", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/postgresql_logo.svg", + "large": "img/postgresql_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/postgres/postgres_query_model.ts b/public/app/plugins/datasource/postgres/postgres_query_model.ts new file mode 100644 index 0000000..0fce8b3 --- /dev/null +++ b/public/app/plugins/datasource/postgres/postgres_query_model.ts @@ -0,0 +1,297 @@ +import { find, map } from 'lodash'; +import { TemplateSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; + +export default class PostgresQueryModel { + target: any; + templateSrv: any; + scopedVars: any; + + /** @ngInject */ + constructor(target: any, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { + this.target = target; + this.templateSrv = templateSrv; + this.scopedVars = scopedVars; + + target.format = target.format || 'time_series'; + target.timeColumn = target.timeColumn || 'time'; + target.metricColumn = target.metricColumn || 'none'; + + target.group = target.group || []; + target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }]; + target.select = target.select || [[{ type: 'column', params: ['value'] }]]; + + // handle pre query gui panels gracefully + if (!('rawQuery' in this.target)) { + if ('rawSql' in target) { + // pre query gui panel + target.rawQuery = true; + } else { + // new panel + target.rawQuery = false; + } + } + + // give interpolateQueryStr access to this + this.interpolateQueryStr = this.interpolateQueryStr.bind(this); + } + + // remove identifier quoting from identifier to use in metadata queries + unquoteIdentifier(value: string) { + if (value[0] === '"' && value[value.length - 1] === '"') { + return value.substring(1, value.length - 1).replace(/""/g, '"'); + } else { + return value; + } + } + + quoteIdentifier(value: any) { + return '"' + String(value).replace(/"/g, '""') + '"'; + } + + quoteLiteral(value: any) { + return "'" + String(value).replace(/'/g, "''") + "'"; + } + + escapeLiteral(value: any) { + return String(value).replace(/'/g, "''"); + } + + hasTimeGroup() { + return find(this.target.group, (g: any) => g.type === 'time'); + } + + hasMetricColumn() { + return this.target.metricColumn !== 'none'; + } + + interpolateQueryStr(value: any, variable: { multi: any; includeAll: any }, defaultFormatFn: any) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return this.escapeLiteral(value); + } + + if (typeof value === 'string') { + return this.quoteLiteral(value); + } + + const escapedValues = map(value, this.quoteLiteral); + return escapedValues.join(','); + } + + render(interpolate?: any) { + const target = this.target; + + // new query with no table set yet + if (!this.target.rawQuery && !('table' in this.target)) { + return ''; + } + + if (!target.rawQuery) { + target.rawSql = this.buildQuery(); + } + + if (interpolate) { + return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr); + } else { + return target.rawSql; + } + } + + hasUnixEpochTimecolumn() { + return ['int4', 'int8', 'float4', 'float8', 'numeric'].indexOf(this.target.timeColumnType) > -1; + } + + buildTimeColumn(alias = true) { + const timeGroup = this.hasTimeGroup(); + let query; + let macro = '$__timeGroup'; + + if (timeGroup) { + let args; + if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') { + args = timeGroup.params.join(','); + } else { + args = timeGroup.params[0]; + } + if (this.hasUnixEpochTimecolumn()) { + macro = '$__unixEpochGroup'; + } + if (alias) { + macro += 'Alias'; + } + query = macro + '(' + this.target.timeColumn + ',' + args + ')'; + } else { + query = this.target.timeColumn; + if (alias) { + query += ' AS "time"'; + } + } + + return query; + } + + buildMetricColumn() { + if (this.hasMetricColumn()) { + return this.target.metricColumn + ' AS metric'; + } + + return ''; + } + + buildValueColumns() { + let query = ''; + for (const column of this.target.select) { + query += ',\n ' + this.buildValueColumn(column); + } + + return query; + } + + buildValueColumn(column: any) { + let query = ''; + + const columnName: any = find(column, (g: any) => g.type === 'column'); + query = columnName.params[0]; + + const aggregate: any = find(column, (g: any) => g.type === 'aggregate' || g.type === 'percentile'); + const windows: any = find(column, (g: any) => g.type === 'window' || g.type === 'moving_window'); + + if (aggregate) { + const func = aggregate.params[0]; + switch (aggregate.type) { + case 'aggregate': + if (func === 'first' || func === 'last') { + query = func + '(' + query + ',' + this.target.timeColumn + ')'; + } else { + query = func + '(' + query + ')'; + } + break; + case 'percentile': + query = func + '(' + aggregate.params[1] + ') WITHIN GROUP (ORDER BY ' + query + ')'; + break; + } + } + + if (windows) { + const overParts = []; + if (this.hasMetricColumn()) { + overParts.push('PARTITION BY ' + this.target.metricColumn); + } + overParts.push('ORDER BY ' + this.buildTimeColumn(false)); + + const over = overParts.join(' '); + let curr: string; + let prev: string; + switch (windows.type) { + case 'window': + switch (windows.params[0]) { + case 'delta': + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = curr + ' - ' + prev; + break; + case 'increase': + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev; + query += ' WHEN ' + prev + ' IS NULL THEN NULL ELSE ' + curr + ' END)'; + break; + case 'rate': + let timeColumn = this.target.timeColumn; + if (aggregate) { + timeColumn = 'min(' + timeColumn + ')'; + } + + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev; + query += ' WHEN ' + prev + ' IS NULL THEN NULL ELSE ' + curr + ' END)'; + query += '/extract(epoch from ' + timeColumn + ' - lag(' + timeColumn + ') OVER (' + over + '))'; + break; + default: + query = windows.params[0] + '(' + query + ') OVER (' + over + ')'; + break; + } + break; + case 'moving_window': + query = windows.params[0] + '(' + query + ') OVER (' + over + ' ROWS ' + windows.params[1] + ' PRECEDING)'; + break; + } + } + + const alias: any = find(column, (g: any) => g.type === 'alias'); + if (alias) { + query += ' AS ' + this.quoteIdentifier(alias.params[0]); + } + + return query; + } + + buildWhereClause() { + let query = ''; + const conditions = map(this.target.where, (tag, index) => { + switch (tag.type) { + case 'macro': + return tag.name + '(' + this.target.timeColumn + ')'; + break; + case 'expression': + return tag.params.join(' '); + break; + } + }); + + if (conditions.length > 0) { + query = '\nWHERE\n ' + conditions.join(' AND\n '); + } + + return query; + } + + buildGroupClause() { + let query = ''; + let groupSection = ''; + + for (let i = 0; i < this.target.group.length; i++) { + const part = this.target.group[i]; + if (i > 0) { + groupSection += ', '; + } + if (part.type === 'time') { + groupSection += '1'; + } else { + groupSection += part.params[0]; + } + } + + if (groupSection.length) { + query = '\nGROUP BY ' + groupSection; + if (this.hasMetricColumn()) { + query += ',2'; + } + } + return query; + } + + buildQuery() { + let query = 'SELECT'; + + query += '\n ' + this.buildTimeColumn(); + if (this.hasMetricColumn()) { + query += ',\n ' + this.buildMetricColumn(); + } + query += this.buildValueColumns(); + + query += '\nFROM ' + this.target.table; + + query += this.buildWhereClause(); + query += this.buildGroupClause(); + + query += '\nORDER BY 1'; + if (this.hasMetricColumn()) { + query += ',2'; + } + + return query; + } +} diff --git a/public/app/plugins/datasource/postgres/query_ctrl.ts b/public/app/plugins/datasource/postgres/query_ctrl.ts new file mode 100644 index 0000000..3974377 --- /dev/null +++ b/public/app/plugins/datasource/postgres/query_ctrl.ts @@ -0,0 +1,670 @@ +import { clone, filter, find, findIndex, indexOf, map } from 'lodash'; +import appEvents from 'app/core/app_events'; +import { PostgresMetaQuery } from './meta_query'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { SqlPart } from 'app/core/components/sql_part/sql_part'; +import PostgresQueryModel from './postgres_query_model'; +import sqlPart from './sql_part'; +import { auto } from 'angular'; +import { PanelEvents, QueryResultMeta } from '@grafana/data'; +import { VariableWithMultiSupport } from 'app/features/variables/types'; +import { TemplateSrv } from '@grafana/runtime'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +const defaultQuery = `SELECT + $__time(time_column), + value1 +FROM + metric_table +WHERE + $__timeFilter(time_column) +`; + +export class PostgresQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + formats: any[]; + queryModel: PostgresQueryModel; + metaBuilder: PostgresMetaQuery; + lastQueryMeta?: QueryResultMeta; + lastQueryError?: string; + showHelp = false; + tableSegment: any; + whereAdd: any; + timeColumnSegment: any; + metricColumnSegment: any; + selectMenu: any[] = []; + selectParts: SqlPart[][] = [[]]; + groupParts: SqlPart[] = []; + whereParts: SqlPart[] = []; + groupAdd: any; + + /** @ngInject */ + constructor( + $scope: any, + $injector: auto.IInjectorService, + private templateSrv: TemplateSrv, + private uiSegmentSrv: any + ) { + super($scope, $injector); + this.target = this.target; + this.queryModel = new PostgresQueryModel(this.target, templateSrv, this.panel.scopedVars); + this.metaBuilder = new PostgresMetaQuery(this.target, this.queryModel); + this.updateProjection(); + + this.formats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + + if (!this.target.rawSql) { + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = 'SELECT 1'; + this.target.rawQuery = true; + } else { + this.target.rawSql = defaultQuery; + this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then((result: any) => { + if (result.length > 0) { + this.target.table = result[0].text; + let segment = this.uiSegmentSrv.newSegment(this.target.table); + this.tableSegment.html = segment.html; + this.tableSegment.value = segment.value; + + this.target.timeColumn = result[1].text; + segment = this.uiSegmentSrv.newSegment(this.target.timeColumn); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + + this.target.timeColumnType = 'timestamp'; + this.target.select = [[{ type: 'column', params: [result[2].text] }]]; + this.updateProjection(); + this.updateRawSqlAndRefresh(); + } + }); + } + } + + if (!this.target.table) { + this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true }); + } else { + this.tableSegment = uiSegmentSrv.newSegment(this.target.table); + } + + this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn); + this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn); + + this.buildSelectMenu(); + this.whereAdd = this.uiSegmentSrv.newPlusButton(); + this.groupAdd = this.uiSegmentSrv.newPlusButton(); + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + } + + updateRawSqlAndRefresh() { + if (!this.target.rawQuery) { + this.target.rawSql = this.queryModel.buildQuery(); + } + + this.panelCtrl.refresh(); + } + + updateProjection() { + this.selectParts = map(this.target.select, (parts: any) => { + return map(parts, sqlPart.create).filter((n) => n); + }); + this.whereParts = map(this.target.where, sqlPart.create).filter((n) => n); + this.groupParts = map(this.target.group, sqlPart.create).filter((n) => n); + } + + updatePersistedParts() { + this.target.select = map(this.selectParts, (selectParts) => { + return map(selectParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + }); + this.target.where = map(this.whereParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params }; + }); + this.target.group = map(this.groupParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + } + + buildSelectMenu() { + this.selectMenu = []; + const aggregates = { + text: 'Aggregate Functions', + value: 'aggregate', + submenu: [ + { text: 'Average', value: 'avg' }, + { text: 'Count', value: 'count' }, + { text: 'Maximum', value: 'max' }, + { text: 'Minimum', value: 'min' }, + { text: 'Sum', value: 'sum' }, + { text: 'Standard deviation', value: 'stddev' }, + { text: 'Variance', value: 'variance' }, + ], + }; + + // first and last aggregate are timescaledb specific + if (this.datasource.jsonData.timescaledb === true) { + aggregates.submenu.push({ text: 'First', value: 'first' }); + aggregates.submenu.push({ text: 'Last', value: 'last' }); + } + + this.selectMenu.push(aggregates); + + // ordered set aggregates require postgres 9.4+ + if (this.datasource.jsonData.postgresVersion >= 904) { + const aggregates2 = { + text: 'Ordered-Set Aggregate Functions', + value: 'percentile', + submenu: [ + { text: 'Percentile (continuous)', value: 'percentile_cont' }, + { text: 'Percentile (discrete)', value: 'percentile_disc' }, + ], + }; + this.selectMenu.push(aggregates2); + } + + const windows = { + text: 'Window Functions', + value: 'window', + submenu: [ + { text: 'Delta', value: 'delta' }, + { text: 'Increase', value: 'increase' }, + { text: 'Rate', value: 'rate' }, + { text: 'Sum', value: 'sum' }, + { text: 'Moving Average', value: 'avg', type: 'moving_window' }, + ], + }; + this.selectMenu.push(windows); + + this.selectMenu.push({ text: 'Alias', value: 'alias' }); + this.selectMenu.push({ text: 'Column', value: 'column' }); + } + + toggleEditorMode() { + if (this.target.rawQuery) { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Warning', + text2: 'Switching to query builder may overwrite your raw SQL.', + icon: 'exclamation-triangle', + yesText: 'Switch', + onConfirm: () => { + this.target.rawQuery = !this.target.rawQuery; + }, + }) + ); + } else { + this.target.rawQuery = !this.target.rawQuery; + } + } + + resetPlusButton(button: { html: any; value: any; type: any; fake: any }) { + const plusButton = this.uiSegmentSrv.newPlusButton(); + button.html = plusButton.html; + button.value = plusButton.value; + button.type = plusButton.type; + button.fake = plusButton.fake; + } + + getTableSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildTableQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + tableChanged() { + this.target.table = this.tableSegment.value; + this.target.where = []; + this.target.group = []; + this.updateProjection(); + + const segment = this.uiSegmentSrv.newSegment('none'); + this.metricColumnSegment.html = segment.html; + this.metricColumnSegment.value = segment.value; + this.target.metricColumn = 'none'; + + const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then((result: any) => { + // check if time column is still valid + if (result.length > 0 && !find(result, (r: any) => r.text === this.target.timeColumn)) { + const segment = this.uiSegmentSrv.newSegment(result[0].text); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + } + return this.timeColumnChanged(false); + }); + const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then((result: any) => { + if (result.length > 0) { + this.target.select = [[{ type: 'column', params: [result[0].text] }]]; + this.updateProjection(); + } + }); + + Promise.all([task1, task2]).then(() => { + this.updateRawSqlAndRefresh(); + }); + } + + getTimeColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('time')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + timeColumnChanged(refresh?: boolean) { + this.target.timeColumn = this.timeColumnSegment.value; + return this.datasource + .metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)) + .then((result: any) => { + if (result.length === 1) { + if (this.target.timeColumnType !== result[0].text) { + this.target.timeColumnType = result[0].text; + } + let partModel; + if (this.queryModel.hasUnixEpochTimecolumn()) { + partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] }); + } else { + partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] }); + } + + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + } + + this.updatePersistedParts(); + if (refresh !== false) { + this.updateRawSqlAndRefresh(); + } + }); + } + + getMetricColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('metric')) + .then(this.transformToSegments({ addNone: true })) + .catch(this.handleQueryError.bind(this)); + } + + metricColumnChanged() { + this.target.metricColumn = this.metricColumnSegment.value; + this.updateRawSqlAndRefresh(); + } + + onDataReceived(dataList: any) { + this.lastQueryError = undefined; + this.lastQueryMeta = dataList[0]?.meta; + } + + onDataError(err: any) { + if (err.data && err.data.results) { + const queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryError = queryRes.error; + } + } + } + + transformToSegments(config: { addNone?: any; addTemplateVars?: any; templateQuoter?: any }) { + return (results: any) => { + const segments = map(results, (segment) => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (config.addTemplateVars) { + for (const variable of this.templateSrv.getVariables()) { + let value; + value = '$' + variable.name; + if (config.templateQuoter && ((variable as unknown) as VariableWithMultiSupport).multi === false) { + value = config.templateQuoter(value); + } + + segments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'template', + value: value, + expandable: true, + }) + ); + } + } + + if (config.addNone) { + segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true })); + } + + return segments; + }; + } + + findAggregateIndex(selectParts: any) { + return findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile'); + } + + findWindowIndex(selectParts: any) { + return findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window'); + } + + addSelectPart(selectParts: any[], item: { value: any }, subItem: { type: any; value: any }) { + let partType = item.value; + if (subItem && subItem.type) { + partType = subItem.type; + } + let partModel = sqlPart.create({ type: partType }); + if (subItem) { + partModel.params[0] = subItem.value; + } + let addAlias = false; + + switch (partType) { + case 'column': + const parts = map(selectParts, (part: any) => { + return sqlPart.create({ type: part.def.type, params: clone(part.params) }); + }); + this.selectParts.push(parts); + break; + case 'percentile': + case 'aggregate': + // add group by if no group by yet + if (this.target.group.length === 0) { + this.addGroup('time', '$__interval'); + } + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + // replace current aggregation + selectParts[aggIndex] = partModel; + } else { + selectParts.splice(1, 0, partModel); + } + if (!find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'moving_window': + case 'window': + const windowIndex = this.findWindowIndex(selectParts); + if (windowIndex !== -1) { + // replace current window function + selectParts[windowIndex] = partModel; + } else { + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + selectParts.splice(aggIndex + 1, 0, partModel); + } else { + selectParts.splice(1, 0, partModel); + } + } + if (!find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'alias': + addAlias = true; + break; + } + + if (addAlias) { + // set initial alias name to column name + partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] }); + if (selectParts[selectParts.length - 1].def.type === 'alias') { + selectParts[selectParts.length - 1] = partModel; + } else { + selectParts.push(partModel); + } + } + + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + } + + removeSelectPart(selectParts: any, part: { def: { type: string } }) { + if (part.def.type === 'column') { + // remove all parts of column unless its last column + if (this.selectParts.length > 1) { + const modelsIndex = indexOf(this.selectParts, selectParts); + this.selectParts.splice(modelsIndex, 1); + } + } else { + const partIndex = indexOf(selectParts, part); + selectParts.splice(partIndex, 1); + } + + this.updatePersistedParts(); + } + + handleSelectPartEvent(selectParts: any, part: { def: any }, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + switch (part.def.type) { + case 'aggregate': + return this.datasource + .metricFindQuery(this.metaBuilder.buildAggregateQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'column': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('value')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeSelectPart(selectParts, part); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + handleGroupPartEvent(part: any, index: any, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeGroup(part, index); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + addGroup(partType: string, value: string) { + let params = [value]; + if (partType === 'time') { + params = ['$__interval', 'none']; + } + const partModel = sqlPart.create({ type: partType, params: params }); + + if (partType === 'time') { + // put timeGroup at start + this.groupParts.splice(0, 0, partModel); + } else { + this.groupParts.push(partModel); + } + + // add aggregates when adding group by + for (const selectParts of this.selectParts) { + if (!selectParts.some((part) => part.def.type === 'aggregate')) { + const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] }); + selectParts.splice(1, 0, aggregate); + if (!selectParts.some((part) => part.def.type === 'alias')) { + const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] }); + selectParts.push(alias); + } + } + } + + this.updatePersistedParts(); + } + + removeGroup(part: { def: { type: string } }, index: number) { + if (part.def.type === 'time') { + // remove aggregations + this.selectParts = map(this.selectParts, (s: any) => { + return filter(s, (part: any) => { + if (part.def.type === 'aggregate' || part.def.type === 'percentile') { + return false; + } + return true; + }); + }); + } + + this.groupParts.splice(index, 1); + this.updatePersistedParts(); + } + + handleWherePartEvent(whereParts: any, part: any, evt: any, index: any) { + switch (evt.name) { + case 'get-param-options': { + switch (evt.param.name) { + case 'left': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'right': + if (['int4', 'int8', 'float4', 'float8', 'timestamp', 'timestamptz'].indexOf(part.datatype) > -1) { + // don't do value lookups for numerical fields + return Promise.resolve([]); + } else { + return this.datasource + .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0])) + .then( + this.transformToSegments({ + addTemplateVars: true, + templateQuoter: (v: string) => { + return this.queryModel.quoteLiteral(v); + }, + }) + ) + .catch(this.handleQueryError.bind(this)); + } + case 'op': + return Promise.resolve(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype))); + default: + return Promise.resolve([]); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => { + if (d.length === 1) { + part.datatype = d[0].text; + } + }); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + // remove element + whereParts.splice(index, 1); + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + getWhereOptions() { + const options = []; + if (this.queryModel.hasUnixEpochTimecolumn()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' })); + } else { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' })); + } + options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' })); + return Promise.resolve(options); + } + + addWhereAction(part: any, index: any) { + switch (this.whereAdd.type) { + case 'macro': { + const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] }); + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + break; + } + default: { + this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] })); + } + } + + this.updatePersistedParts(); + this.resetPlusButton(this.whereAdd); + this.updateRawSqlAndRefresh(); + } + + getGroupOptions() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('group')) + .then((tags: any) => { + const options = []; + if (!this.queryModel.hasTimeGroup()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' })); + } + for (const tag of tags) { + options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text })); + } + return options; + }) + .catch(this.handleQueryError.bind(this)); + } + + addGroupAction() { + switch (this.groupAdd.value) { + default: { + this.addGroup(this.groupAdd.type, this.groupAdd.value); + } + } + + this.resetPlusButton(this.groupAdd); + this.updateRawSqlAndRefresh(); + } + + handleQueryError(err: any): any[] { + this.error = err.message || 'Failed to issue metric query'; + return []; + } +} diff --git a/public/app/plugins/datasource/postgres/response_parser.ts b/public/app/plugins/datasource/postgres/response_parser.ts new file mode 100644 index 0000000..53c20e9 --- /dev/null +++ b/public/app/plugins/datasource/postgres/response_parser.ts @@ -0,0 +1,125 @@ +import { AnnotationEvent, DataFrame, FieldType, MetricFindValue } from '@grafana/data'; +import { BackendDataSourceResponse, FetchResponse, toDataQueryResponse } from '@grafana/runtime'; +import { map } from 'lodash'; + +export default class ResponseParser { + transformMetricFindResponse(raw: FetchResponse): MetricFindValue[] { + const frames = toDataQueryResponse(raw).data as DataFrame[]; + + if (!frames || !frames.length) { + return []; + } + + const frame = frames[0]; + + const values: MetricFindValue[] = []; + const textField = frame.fields.find((f) => f.name === '__text'); + const valueField = frame.fields.find((f) => f.name === '__value'); + + if (textField && valueField) { + for (let i = 0; i < textField.values.length; i++) { + values.push({ text: '' + textField.values.get(i), value: '' + valueField.values.get(i) }); + } + } else { + const textFields = frame.fields.filter((f) => f.type === FieldType.string); + if (textFields) { + values.push( + ...textFields + .flatMap((f) => f.values.toArray()) + .map((v) => ({ + text: '' + v, + })) + ); + } + } + + return Array.from(new Set(values.map((v) => v.text))).map((text) => ({ + text, + value: values.find((v) => v.text === text)?.value, + })); + } + + transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({ + text: rows[i][textColIndex], + value: rows[i][valueColIndex], + }); + } + } + + return res; + } + + transformToSimpleList(rows: any[][]) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + res.push(rows[i][j]); + } + } + + const unique = Array.from(new Set(res)); + + return map(unique, (value) => { + return { text: value }; + }); + } + + findColIndex(columns: any[], colName: string) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res: any, key: any) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + async transformAnnotationResponse(options: any, data: BackendDataSourceResponse): Promise { + const frames = toDataQueryResponse({ data: data }).data as DataFrame[]; + const frame = frames[0]; + const timeField = frame.fields.find((f) => f.name === 'time'); + + if (!timeField) { + throw new Error('Missing mandatory time column (with time column alias) in annotation query'); + } + + const timeEndField = frame.fields.find((f) => f.name === 'timeend'); + const textField = frame.fields.find((f) => f.name === 'text'); + const tagsField = frame.fields.find((f) => f.name === 'tags'); + + const list: AnnotationEvent[] = []; + for (let i = 0; i < frame.length; i++) { + const timeEnd = timeEndField && timeEndField.values.get(i) ? Math.floor(timeEndField.values.get(i)) : undefined; + list.push({ + annotation: options.annotation, + time: Math.floor(timeField.values.get(i)), + timeEnd, + text: textField && textField.values.get(i) ? textField.values.get(i) : '', + tags: + tagsField && tagsField.values.get(i) + ? tagsField.values + .get(i) + .trim() + .split(/\s*,\s*/) + : [], + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/postgres/specs/datasource.test.ts b/public/app/plugins/datasource/postgres/specs/datasource.test.ts new file mode 100644 index 0000000..77920fb --- /dev/null +++ b/public/app/plugins/datasource/postgres/specs/datasource.test.ts @@ -0,0 +1,634 @@ +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { FetchResponse } from '@grafana/runtime'; +import { + dataFrameToJSON, + DataQueryRequest, + DataSourceInstanceSettings, + dateTime, + MutableDataFrame, + toUtc, +} from '@grafana/data'; + +import { PostgresDatasource } from '../datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { TimeSrv } from '../../../../features/dashboard/services/TimeSrv'; +import { PostgresOptions, PostgresQuery } from '../types'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +jest.mock('@grafana/runtime/src/services', () => ({ + ...((jest.requireActual('@grafana/runtime/src/services') as unknown) as object), + getBackendSrv: () => backendSrv, + getDataSourceSrv: () => { + return { + getInstanceSettings: () => ({ id: 8674 }), + }; + }, +})); + +describe('PostgreSQLDatasource', () => { + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + const setupTestContext = (data: any) => { + jest.clearAllMocks(); + fetchMock.mockImplementation(() => of(createFetchResponse(data))); + const instanceSettings = ({ + jsonData: { + defaultProject: 'testproject', + }, + } as unknown) as DataSourceInstanceSettings; + const templateSrv: TemplateSrv = new TemplateSrv(); + const raw = { + from: toUtc('2018-04-25 10:00'), + to: toUtc('2018-04-25 11:00'), + }; + const timeSrvMock = ({ + timeRange: () => ({ + from: raw.from, + to: raw.to, + raw: raw, + }), + } as unknown) as TimeSrv; + const variable = { ...initialCustomVariableModelState }; + const ds = new PostgresDatasource(instanceSettings, templateSrv, timeSrvMock); + + return { ds, templateSrv, timeSrvMock, variable }; + }; + + // https://rxjs-dev.firebaseapp.com/guide/testing/marble-testing + const runMarbleTest = (args: { + options: any; + values: { [marble: string]: FetchResponse }; + marble: string; + expectedValues: { [marble: string]: any }; + expectedMarble: string; + }) => { + const { expectedValues, expectedMarble, options, values, marble } = args; + const scheduler: TestScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + const { ds } = setupTestContext({}); + + scheduler.run(({ cold, expectObservable }) => { + const source = cold(marble, values); + jest.clearAllMocks(); + fetchMock.mockImplementation(() => source); + + const result = ds.query(options); + expectObservable(result).toBe(expectedMarble, expectedValues); + }); + }; + + describe('When performing a time series query', () => { + it('should transform response correctly', () => { + const options = { + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'time_series', + rawQuery: true, + rawSql: 'select time, metric from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + }, + ], + }; + const response = { + results: { + A: { + refId: 'A', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'time', values: [1599643351085] }, + { name: 'metric', values: [30.226249741223704], labels: { metric: 'America' } }, + ], + meta: { + executedQueryString: 'select time, metric from grafana_metric', + }, + }) + ), + ], + }, + }, + }; + + const values = { a: createFetchResponse(response) }; + const marble = '-a|'; + const expectedMarble = '-a|'; + const expectedValues = { + a: { + data: [ + { + fields: [ + { + config: {}, + entities: {}, + name: 'time', + type: 'time', + values: { + buffer: [1599643351085], + }, + }, + { + config: {}, + entities: {}, + labels: { + metric: 'America', + }, + name: 'metric', + type: 'number', + values: { + buffer: [30.226249741223704], + }, + }, + ], + length: 1, + meta: { + executedQueryString: 'select time, metric from grafana_metric', + }, + name: undefined, + refId: 'A', + }, + ], + state: 'Done', + }, + }; + + runMarbleTest({ options, marble, values, expectedMarble, expectedValues }); + }); + }); + + describe('When performing a table query', () => { + it('should transform response correctly', () => { + const options = { + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'table', + rawQuery: true, + rawSql: 'select time, metric, value from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + }, + ], + }; + const response = { + results: { + A: { + refId: 'A', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'time', values: [1599643351085] }, + { name: 'metric', values: ['America'] }, + { name: 'value', values: [30.226249741223704] }, + ], + meta: { + executedQueryString: 'select time, metric, value from grafana_metric', + }, + }) + ), + ], + }, + }, + }; + + const values = { a: createFetchResponse(response) }; + const marble = '-a|'; + const expectedMarble = '-a|'; + const expectedValues = { + a: { + data: [ + { + fields: [ + { + config: {}, + entities: {}, + name: 'time', + type: 'time', + values: { + buffer: [1599643351085], + }, + }, + { + config: {}, + entities: {}, + name: 'metric', + type: 'string', + values: { + buffer: ['America'], + }, + }, + { + config: {}, + entities: {}, + name: 'value', + type: 'number', + values: { + buffer: [30.226249741223704], + }, + }, + ], + length: 1, + meta: { + executedQueryString: 'select time, metric, value from grafana_metric', + }, + name: undefined, + refId: 'A', + }, + ], + state: 'Done', + }, + }; + + runMarbleTest({ options, marble, values, expectedMarble, expectedValues }); + }); + }); + + describe('When performing a query with hidden target', () => { + it('should return empty result and backendSrv.fetch should not be called', async () => { + const options = ({ + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'table', + rawQuery: true, + rawSql: 'select time, metric, value from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + hide: true, + }, + ], + } as unknown) as DataQueryRequest; + + const { ds } = setupTestContext({}); + + await expect(ds.query(options)).toEmitValuesWith((received) => { + expect(received[0]).toEqual({ data: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + }); + + describe('When performing annotationQuery', () => { + let results: any; + const annotationName = 'MyAnno'; + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time, title, text, tags from table;', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + }; + const response = { + results: { + MyAnno: { + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'time', values: [1432288355, 1432288390, 1432288400] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + { name: 'tags', values: ['TagA,TagB', ' TagB , TagC', null] }, + ], + }) + ), + ], + }, + }, + }; + + beforeEach(async () => { + const { ds } = setupTestContext(response); + results = await ds.annotationQuery(options); + }); + + it('should return annotation list', async () => { + expect(results.length).toBe(3); + + expect(results[0].text).toBe('some text'); + expect(results[0].tags[0]).toBe('TagA'); + expect(results[0].tags[1]).toBe('TagB'); + + expect(results[1].tags[0]).toBe('TagB'); + expect(results[1].tags[1]).toBe('TagC'); + + expect(results[2].tags.length).toBe(0); + }); + }); + + describe('When performing metricFindQuery', () => { + it('should return list of all column values', async () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + const { ds } = setupTestContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(6); + expect(results[0].text).toBe('aTitle'); + expect(results[5].text).toBe('some text3'); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => { + it('should return list of all column values', async () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + const { ds } = setupTestContext(response); + const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' }); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe( + "select title from atable where title LIKE 'aTit%'" + ); + expect(results).toEqual([ + { text: 'aTitle' }, + { text: 'aTitle2' }, + { text: 'aTitle3' }, + { text: 'some text' }, + { text: 'some text2' }, + { text: 'some text3' }, + ]); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => { + it('should return list of all column values', async () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + { name: 'text', values: ['some text', 'some text2', 'some text3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + + const { ds } = setupTestContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'"); + expect(results).toEqual([ + { text: 'aTitle' }, + { text: 'aTitle2' }, + { text: 'aTitle3' }, + { text: 'some text' }, + { text: 'some text2' }, + { text: 'some text3' }, + ]); + }); + }); + + describe('When performing metricFindQuery with key, value columns', () => { + it('should return list of as text, value', async () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: '__value', values: ['value1', 'value2', 'value3'] }, + { name: '__text', values: ['aTitle', 'aTitle2', 'aTitle3'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + const { ds } = setupTestContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results).toEqual([ + { text: 'aTitle', value: 'value1' }, + { text: 'aTitle2', value: 'value2' }, + { text: 'aTitle3', value: 'value3' }, + ]); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { + it('should return list of unique keys', async () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + refId: 'tempvar', + frames: [ + dataFrameToJSON( + new MutableDataFrame({ + fields: [ + { name: '__text', values: ['aTitle', 'aTitle', 'aTitle'] }, + { name: '__value', values: ['same', 'same', 'diff'] }, + ], + meta: { + executedQueryString: 'select * from atable', + }, + }) + ), + ], + }, + }, + }; + const { ds } = setupTestContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results).toEqual([{ text: 'aTitle', value: 'same' }]); + }); + }); + + describe('When interpolating variables', () => { + describe('and value is a string', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTestContext({}); + expect(ds.interpolateVariable('abc', variable)).toEqual('abc'); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTestContext({}); + expect(ds.interpolateVariable((1000 as unknown) as string, variable)).toEqual(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + const { ds, variable } = setupTestContext({}); + expect(ds.interpolateVariable(['a', 'b', 'c'], variable)).toEqual("'a','b','c'"); + }); + }); + + describe('and variable allows multi-value and is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTestContext({}); + variable.multi = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + + describe('and variable contains single quote', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTestContext({}); + variable.multi = true; + expect(ds.interpolateVariable("a'bc", variable)).toEqual("'a''bc'"); + expect(ds.interpolateVariable("a'b'c", variable)).toEqual("'a''b''c'"); + }); + }); + + describe('and variable allows all and is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTestContext({}); + variable.includeAll = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + }); + + describe('targetContainsTemplate', () => { + it('given query that contains template variable it should return true', () => { + const rawSql = `SELECT + $__timeGroup("createdAt",'$summarize'), + avg(value) as "value", + hostname as "metric" + FROM + grafana_metric + WHERE + $__timeFilter("createdAt") AND + measurement = 'logins.count' AND + hostname IN($host) + GROUP BY time, metric + ORDER BY time`; + const query = { + rawSql, + rawQuery: true, + }; + const { templateSrv, ds } = setupTestContext({}); + + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + + expect(ds.targetContainsTemplate(query)).toBeTruthy(); + }); + + it('given query that only contains global template variable it should return false', () => { + const rawSql = `SELECT + $__timeGroup("createdAt",'$__interval'), + avg(value) as "value", + hostname as "metric" + FROM + grafana_metric + WHERE + $__timeFilter("createdAt") AND + measurement = 'logins.count' + GROUP BY time, metric + ORDER BY time`; + const query = { + rawSql, + rawQuery: true, + }; + const { templateSrv, ds } = setupTestContext({}); + + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + + expect(ds.targetContainsTemplate(query)).toBeFalsy(); + }); + }); +}); + +const createFetchResponse = (data: T): FetchResponse => ({ + data, + status: 200, + url: 'http://localhost:3000/api/query', + config: { url: 'http://localhost:3000/api/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: ({} as unknown) as Headers, + ok: true, +}); diff --git a/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts b/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts new file mode 100644 index 0000000..e2057b6 --- /dev/null +++ b/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts @@ -0,0 +1,174 @@ +import PostgresQueryModel from '../postgres_query_model'; +import { TemplateSrv } from 'app/features/templating/template_srv'; + +describe('PostgresQuery', () => { + // @ts-ignore + const templateSrv: TemplateSrv = { + replace: jest.fn((text) => text) as any, + }; + + describe('When initializing', () => { + it('should not be in SQL mode', () => { + const query = new PostgresQueryModel({}, templateSrv); + expect(query.target.rawQuery).toBe(false); + }); + it('should be in SQL mode for pre query builder queries', () => { + const query = new PostgresQueryModel({ rawSql: 'SELECT 1' }, templateSrv); + expect(query.target.rawQuery).toBe(true); + }); + }); + + describe('When generating time column SQL', () => { + const query = new PostgresQueryModel({}, templateSrv); + + query.target.timeColumn = 'time'; + expect(query.buildTimeColumn()).toBe('time AS "time"'); + query.target.timeColumn = '"time"'; + expect(query.buildTimeColumn()).toBe('"time" AS "time"'); + }); + + describe('When generating time column SQL with group by time', () => { + let query = new PostgresQueryModel( + { timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'none'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m)'); + expect(query.buildTimeColumn(false)).toBe('$__timeGroup(time,5m)'); + + query = new PostgresQueryModel( + { timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'NULL'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m,NULL)'); + + query = new PostgresQueryModel( + { timeColumn: 'time', timeColumnType: 'int4', group: [{ type: 'time', params: ['5m', 'none'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__unixEpochGroupAlias(time,5m)'); + expect(query.buildTimeColumn(false)).toBe('$__unixEpochGroup(time,5m)'); + }); + + describe('When generating metric column SQL', () => { + const query = new PostgresQueryModel({}, templateSrv); + + query.target.metricColumn = 'host'; + expect(query.buildMetricColumn()).toBe('host AS metric'); + query.target.metricColumn = '"host"'; + expect(query.buildMetricColumn()).toBe('"host" AS metric'); + }); + + describe('When generating value column SQL', () => { + const query = new PostgresQueryModel({}, templateSrv); + + let column = [{ type: 'column', params: ['value'] }]; + expect(query.buildValueColumn(column)).toBe('value'); + column = [ + { type: 'column', params: ['value'] }, + { type: 'alias', params: ['alias'] }, + ]; + expect(query.buildValueColumn(column)).toBe('value AS "alias"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + ]; + expect(query.buildValueColumn(column)).toBe('max(v) AS "a"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN v >= lag(v) OVER (ORDER BY time) ' + + 'THEN v - lag(v) OVER (ORDER BY time) ' + + 'WHEN lag(v) OVER (ORDER BY time) IS NULL THEN NULL ELSE v END) AS "a"' + ); + }); + + describe('When generating value column SQL with metric column', () => { + const query = new PostgresQueryModel({}, templateSrv); + query.target.metricColumn = 'host'; + + let column = [{ type: 'column', params: ['value'] }]; + expect(query.buildValueColumn(column)).toBe('value'); + column = [ + { type: 'column', params: ['value'] }, + { type: 'alias', params: ['alias'] }, + ]; + expect(query.buildValueColumn(column)).toBe('value AS "alias"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + ]; + expect(query.buildValueColumn(column)).toBe('max(v) AS "a"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN v >= lag(v) OVER (PARTITION BY host ORDER BY time) ' + + 'THEN v - lag(v) OVER (PARTITION BY host ORDER BY time) ' + + 'WHEN lag(v) OVER (PARTITION BY host ORDER BY time) IS NULL THEN NULL ELSE v END) AS "a"' + ); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN max(v) >= lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' + + 'THEN max(v) - lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' + + 'WHEN lag(max(v)) OVER (PARTITION BY host ORDER BY time) IS NULL THEN NULL ELSE max(v) END) AS "a"' + ); + }); + + describe('When generating WHERE clause', () => { + const query = new PostgresQueryModel({ where: [] }, templateSrv); + + expect(query.buildWhereClause()).toBe(''); + + query.target.timeColumn = 't'; + query.target.where = [{ type: 'macro', name: '$__timeFilter' }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t)'); + + query.target.where = [{ type: 'expression', params: ['v', '=', '1'] }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n v = 1'); + + query.target.where = [ + { type: 'macro', name: '$__timeFilter' }, + { type: 'expression', params: ['v', '=', '1'] }, + ]; + expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t) AND\n v = 1'); + }); + + describe('When generating GROUP BY clause', () => { + const query = new PostgresQueryModel({ group: [], metricColumn: 'none' }, templateSrv); + + expect(query.buildGroupClause()).toBe(''); + query.target.group = [{ type: 'time', params: ['5m'] }]; + expect(query.buildGroupClause()).toBe('\nGROUP BY 1'); + query.target.metricColumn = 'm'; + expect(query.buildGroupClause()).toBe('\nGROUP BY 1,2'); + }); + + describe('When generating complete statement', () => { + const target: any = { + timeColumn: 't', + table: 'table', + select: [[{ type: 'column', params: ['value'] }]], + where: [], + }; + let result = 'SELECT\n t AS "time",\n value\nFROM table\nORDER BY 1'; + const query = new PostgresQueryModel(target, templateSrv); + + expect(query.buildQuery()).toBe(result); + + query.target.metricColumn = 'm'; + result = 'SELECT\n t AS "time",\n m AS metric,\n value\nFROM table\nORDER BY 1,2'; + expect(query.buildQuery()).toBe(result); + }); +}); diff --git a/public/app/plugins/datasource/postgres/sql_part.ts b/public/app/plugins/datasource/postgres/sql_part.ts new file mode 100644 index 0000000..34ac81b --- /dev/null +++ b/public/app/plugins/datasource/postgres/sql_part.ts @@ -0,0 +1,137 @@ +import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part'; + +const index: any[] = []; + +function createPart(part: any): any { + const def = index[part.type]; + if (!def) { + return null; + } + + return new SqlPart(part, def); +} + +function register(options: any) { + index[options.type] = new SqlPartDef(options); +} + +register({ + type: 'column', + style: 'label', + params: [{ type: 'column', dynamicLookup: true }], + defaultParams: ['value'], +}); + +register({ + type: 'expression', + style: 'expression', + label: 'Expr:', + params: [ + { name: 'left', type: 'string', dynamicLookup: true }, + { name: 'op', type: 'string', dynamicLookup: true }, + { name: 'right', type: 'string', dynamicLookup: true }, + ], + defaultParams: ['value', '=', 'value'], +}); + +register({ + type: 'macro', + style: 'label', + label: 'Macro:', + params: [], + defaultParams: [], +}); + +register({ + type: 'aggregate', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'], + }, + ], + defaultParams: ['avg'], +}); + +register({ + type: 'percentile', + label: 'Aggregate:', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['percentile_cont', 'percentile_disc'], + }, + { + name: 'fraction', + type: 'number', + options: ['0.5', '0.75', '0.9', '0.95', '0.99'], + }, + ], + defaultParams: ['percentile_cont', '0.95'], +}); + +register({ + type: 'alias', + style: 'label', + params: [{ name: 'name', type: 'string', quote: 'double' }], + defaultParams: ['alias'], +}); + +register({ + type: 'time', + style: 'function', + label: 'time', + params: [ + { + name: 'interval', + type: 'interval', + options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + { + name: 'fill', + type: 'string', + options: ['none', 'NULL', 'previous', '0'], + }, + ], + defaultParams: ['$__interval', 'none'], +}); + +register({ + type: 'window', + style: 'label', + params: [ + { + name: 'function', + type: 'string', + options: ['delta', 'increase', 'rate', 'sum'], + }, + ], + defaultParams: ['increase'], +}); + +register({ + type: 'moving_window', + style: 'label', + label: 'Moving Window:', + params: [ + { + name: 'function', + type: 'string', + options: ['avg'], + }, + { + name: 'window_size', + type: 'number', + options: ['3', '5', '7', '10', '20'], + }, + ], + defaultParams: ['avg', '5'], +}); + +export default { + create: createPart, +}; diff --git a/public/app/plugins/datasource/postgres/types.ts b/public/app/plugins/datasource/postgres/types.ts new file mode 100644 index 0000000..8a9c679 --- /dev/null +++ b/public/app/plugins/datasource/postgres/types.ts @@ -0,0 +1,21 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; + +export interface PostgresQueryForInterpolation { + alias?: any; + format?: any; + rawSql?: any; + refId: any; + hide?: any; +} + +export interface PostgresOptions extends DataSourceJsonData { + timeInterval: string; +} + +export type ResultFormat = 'time_series' | 'table'; + +export interface PostgresQuery extends DataQuery { + alias?: string; + format?: ResultFormat; + rawSql?: any; +} diff --git a/public/app/plugins/datasource/prometheus/README.md b/public/app/plugins/datasource/prometheus/README.md new file mode 100644 index 0000000..2c44605 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/README.md @@ -0,0 +1,7 @@ +# Prometheus Data Source - Native Plugin + +Grafana ships with **built in** support for Prometheus, the open-source service monitoring system and time series database. + +Read more about it here: + +[http://docs.grafana.org/datasources/prometheus/](http://docs.grafana.org/datasources/prometheus/) diff --git a/public/app/plugins/datasource/prometheus/add_label_to_query.test.ts b/public/app/plugins/datasource/prometheus/add_label_to_query.test.ts new file mode 100644 index 0000000..e4fb6ab --- /dev/null +++ b/public/app/plugins/datasource/prometheus/add_label_to_query.test.ts @@ -0,0 +1,129 @@ +import { addLabelToQuery, addLabelToSelector } from './add_label_to_query'; + +describe('addLabelToQuery()', () => { + it('should add label to simple query', () => { + expect(() => { + addLabelToQuery('foo', '', ''); + }).toThrow(); + expect(addLabelToQuery('foo', 'bar', 'baz')).toBe('foo{bar="baz"}'); + expect(addLabelToQuery('foo{}', 'bar', 'baz')).toBe('foo{bar="baz"}'); + expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz')).toBe('foo{bar="baz",x="yy"}'); + expect(addLabelToQuery('metric > 0.001', 'foo', 'bar')).toBe('metric{foo="bar"} > 0.001'); + }); + + it('should add custom operator', () => { + expect(addLabelToQuery('foo{}', 'bar', 'baz', '!=')).toBe('foo{bar!="baz"}'); + expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!=')).toBe('foo{bar!="baz",x="yy"}'); + }); + + it('should not modify ranges', () => { + expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])'); + }); + + it('should detect in-order function use', () => { + expect(addLabelToQuery('sum by (xx) (foo)', 'bar', 'baz')).toBe('sum by (xx) (foo{bar="baz"})'); + }); + + it('should convert number Infinity to +Inf', () => { + expect( + addLabelToQuery('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket[5m])) by (le)', 'le', Infinity) + ).toBe('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket{le="+Inf"}[5m])) by (le)'); + }); + + it('should handle selectors with punctuation', () => { + expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe( + 'foo{bar="baz",instance="my-host.com:9100"}' + ); + expect(addLabelToQuery('foo:metric:rate1m', 'bar', 'baz')).toBe('foo:metric:rate1m{bar="baz"}'); + expect(addLabelToQuery('avg(foo:metric:rate1m{a="b"})', 'bar', 'baz')).toBe( + 'avg(foo:metric:rate1m{a="b",bar="baz"})' + ); + expect(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz')).toBe('foo{bar="baz",list="a,b,c"}'); + }); + + it('should work on arithmetical expressions', () => { + expect(addLabelToQuery('foo + foo', 'bar', 'baz')).toBe('foo{bar="baz"} + foo{bar="baz"}'); + expect(addLabelToQuery('foo{x="yy"} + metric', 'bar', 'baz')).toBe('foo{bar="baz",x="yy"} + metric{bar="baz"}'); + expect(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz')).toBe('avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})'); + expect(addLabelToQuery('foo{x="yy"} * metric{y="zz",a="bb"} * metric2', 'bar', 'baz')).toBe( + 'foo{bar="baz",x="yy"} * metric{a="bb",bar="baz",y="zz"} * metric2{bar="baz"}' + ); + }); + + it('should not add duplicate labels to a query', () => { + expect(addLabelToQuery(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!='), 'bar', 'baz', '!=')).toBe( + 'foo{bar!="baz",x="yy"}' + ); + expect(addLabelToQuery(addLabelToQuery('rate(metric[1m])', 'foo', 'bar'), 'foo', 'bar')).toBe( + 'rate(metric{foo="bar"}[1m])' + ); + expect(addLabelToQuery(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz'), 'bar', 'baz')).toBe( + 'foo{bar="baz",list="a,b,c"}' + ); + expect(addLabelToQuery(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz'), 'bar', 'baz')).toBe( + 'avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})' + ); + }); + + it('should not remove filters', () => { + expect(addLabelToQuery('{x="y"} |="yy"', 'bar', 'baz')).toBe('{bar="baz",x="y"} |="yy"'); + expect(addLabelToQuery('{x="y"} |="yy" !~"xx"', 'bar', 'baz')).toBe('{bar="baz",x="y"} |="yy" !~"xx"'); + }); + + it('should add label to query properly with Loki datasource', () => { + expect(addLabelToQuery('{job="grafana"} |= "foo-bar"', 'filename', 'test.txt', undefined, true)).toBe( + '{filename="test.txt",job="grafana"} |= "foo-bar"' + ); + }); + + it('should add labels to metrics with logical operators', () => { + expect(addLabelToQuery('foo_info or bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} or bar_info{bar="baz"}'); + expect(addLabelToQuery('foo_info and bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} and bar_info{bar="baz"}'); + }); + + it('should not add ad-hoc filter to template variables', () => { + expect(addLabelToQuery('sum(rate({job="foo"}[2m])) by (value $variable)', 'bar', 'baz')).toBe( + 'sum(rate({bar="baz",job="foo"}[2m])) by (value $variable)' + ); + }); + + it('should not add ad-hoc filter to range', () => { + expect(addLabelToQuery('avg(rate((my_metric{job="foo"} > 0)[3h:])) by (label)', 'bar', 'baz')).toBe( + 'avg(rate((my_metric{bar="baz",job="foo"} > 0)[3h:])) by (label)' + ); + }); + it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => { + expect( + addLabelToQuery( + 'max by (id, name, type) (my_metric{type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (my_metric) * 1000', + 'bar', + 'baz' + ) + ).toBe( + 'max by (id, name, type) (my_metric{bar="baz",type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (my_metric{bar="baz"}) * 1000' + ); + }); + it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => { + expect(addLabelToQuery('rate(my_metric[${__range_s}s])', 'bar', 'baz')).toBe( + 'rate(my_metric{bar="baz"}[${__range_s}s])' + ); + }); + it('should not add ad-hoc filter to labels to math operations', () => { + expect(addLabelToQuery('count(my_metric{job!="foo"} < (5*1024*1024*1024) or vector(0)) - 1', 'bar', 'baz')).toBe( + 'count(my_metric{bar="baz",job!="foo"} < (5*1024*1024*1024) or vector(0)) - 1' + ); + }); +}); + +describe('addLabelToSelector()', () => { + test('should add a label to an empty selector', () => { + expect(addLabelToSelector('{}', 'foo', 'bar')).toBe('{foo="bar"}'); + expect(addLabelToSelector('', 'foo', 'bar')).toBe('{foo="bar"}'); + }); + test('should add a label to a selector', () => { + expect(addLabelToSelector('{foo="bar"}', 'baz', '42')).toBe('{baz="42",foo="bar"}'); + }); + test('should add a label to a selector with custom operator', () => { + expect(addLabelToSelector('{}', 'baz', '42', '!=')).toBe('{baz!="42"}'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/add_label_to_query.ts b/public/app/plugins/datasource/prometheus/add_label_to_query.ts new file mode 100644 index 0000000..b77eba9 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/add_label_to_query.ts @@ -0,0 +1,143 @@ +import { chain, isEqual } from 'lodash'; + +const keywords = 'by|without|on|ignoring|group_left|group_right|bool'; +const logicalOperators = 'or|and|unless'; + +// Duplicate from mode-prometheus.js, which can't be used in tests due to global ace not being loaded. +const builtInWords = [ + keywords, + logicalOperators, + 'count|count_values|min|max|avg|sum|stddev|stdvar|bottomk|topk|quantile', + 'true|false|null|__name__|job', + 'abs|absent|ceil|changes|clamp_max|clamp_min|count_scalar|day_of_month|day_of_week|days_in_month|delta|deriv', + 'drop_common_labels|exp|floor|histogram_quantile|holt_winters|hour|idelta|increase|irate|label_replace|ln|log2', + 'log10|minute|month|predict_linear|rate|resets|round|scalar|sort|sort_desc|sqrt|time|vector|year|avg_over_time', + 'min_over_time|max_over_time|sum_over_time|count_over_time|quantile_over_time|stddev_over_time|stdvar_over_time', +] + .join('|') + .split('|'); + +// We want to extract all possible metrics and also keywords +const metricsAndKeywordsRegexp = /([A-Za-z:][\w:]*)\b(?![\]{=!",])/g; +// Safari currently doesn't support negative lookbehind. When it does, we should refactor this. +// We are creating 2 matching groups. (\$) is for the Grafana's variables such as ${__rate_s}. We want to ignore +// ${__rate_s} and not add variable to it. +const selectorRegexp = /(\$)?{([^{]*)}/g; + +export function addLabelToQuery( + query: string, + key: string, + value: string | number, + operator?: string, + hasNoMetrics?: boolean +): string { + if (!key || !value) { + throw new Error('Need label to add to query.'); + } + + // We need to make sure that we convert the value back to string because it may be a number + const transformedValue = value === Infinity ? '+Inf' : value.toString(); + + // Add empty selectors to bare metric names + let previousWord: string; + + query = query.replace(metricsAndKeywordsRegexp, (match, word, offset) => { + const isMetric = isWordMetric(query, word, offset, previousWord, hasNoMetrics); + previousWord = word; + + return isMetric ? `${word}{}` : word; + }); + + // Adding label to existing selectors + let match = selectorRegexp.exec(query); + const parts = []; + let lastIndex = 0; + let suffix = ''; + + while (match) { + const prefix = query.slice(lastIndex, match.index); + lastIndex = match.index + match[2].length + 2; + suffix = query.slice(match.index + match[0].length); + // If we matched 1st group, we know it is Grafana's variable and we don't want to add labels + if (match[1]) { + parts.push(prefix); + parts.push(match[0]); + } else { + // If we didn't match first group, we are inside selector and we want to add labels + const selector = match[2]; + const selectorWithLabel = addLabelToSelector(selector, key, transformedValue, operator); + parts.push(prefix, selectorWithLabel); + } + + match = selectorRegexp.exec(query); + } + + parts.push(suffix); + return parts.join(''); +} + +const labelRegexp = /(\w+)\s*(=|!=|=~|!~)\s*("[^"]*")/g; + +export function addLabelToSelector(selector: string, labelKey: string, labelValue: string, labelOperator?: string) { + const parsedLabels = []; + + // Split selector into labels + if (selector) { + let match = labelRegexp.exec(selector); + while (match) { + parsedLabels.push({ key: match[1], operator: match[2], value: match[3] }); + match = labelRegexp.exec(selector); + } + } + + // Add new label + const operatorForLabelKey = labelOperator || '='; + parsedLabels.push({ key: labelKey, operator: operatorForLabelKey, value: `"${labelValue}"` }); + + // Sort labels by key and put them together + const formatted = chain(parsedLabels) + .uniqWith(isEqual) + .compact() + .sortBy('key') + .map(({ key, operator, value }) => `${key}${operator}${value}`) + .value() + .join(','); + + return `{${formatted}}`; +} + +function isPositionInsideChars(text: string, position: number, openChar: string, closeChar: string) { + const nextSelectorStart = text.slice(position).indexOf(openChar); + const nextSelectorEnd = text.slice(position).indexOf(closeChar); + return nextSelectorEnd > -1 && (nextSelectorStart === -1 || nextSelectorStart > nextSelectorEnd); +} + +function isWordMetric(query: string, word: string, offset: number, previousWord: string, hasNoMetrics?: boolean) { + const insideSelector = isPositionInsideChars(query, offset, '{', '}'); + // Handle "sum by (key) (metric)" + const previousWordIsKeyWord = previousWord && keywords.split('|').indexOf(previousWord) > -1; + // Check for colon as as "word boundary" symbol + const isColonBounded = word.endsWith(':'); + // Check for words that start with " which means that they are not metrics + const startsWithQuote = query[offset - 1] === '"'; + // Check for template variables + const isTemplateVariable = query[offset - 1] === '$'; + // Check for time units + const isTimeUnit = ['s', 'm', 'h', 'd', 'w'].includes(word) && Boolean(Number(query[offset - 1])); + + if ( + !hasNoMetrics && + !insideSelector && + !isColonBounded && + !previousWordIsKeyWord && + !startsWithQuote && + !isTemplateVariable && + !isTimeUnit && + builtInWords.indexOf(word) === -1 + ) { + return true; + } + return false; +} + +export default addLabelToQuery; diff --git a/public/app/plugins/datasource/prometheus/components/Label.tsx b/public/app/plugins/datasource/prometheus/components/Label.tsx new file mode 100644 index 0000000..43831b2 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/Label.tsx @@ -0,0 +1,123 @@ +import React, { forwardRef, HTMLAttributes } from 'react'; +import { cx, css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; +// @ts-ignore +import Highlighter from 'react-highlight-words'; + +/** + * @public + */ +export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent) => void; + +export interface Props extends Omit, 'onClick'> { + name: string; + active?: boolean; + loading?: boolean; + searchTerm?: string; + value?: string; + facets?: number; + onClick?: OnLabelClick; +} + +/** + * TODO #33976: Create a common, shared component with public/app/plugins/datasource/loki/components/LokiLabel.tsx + */ +export const Label = forwardRef( + ({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, ...rest }, ref) => { + const theme = useTheme2(); + const styles = getLabelStyles(theme); + const searchWords = searchTerm ? [searchTerm] : []; + + const onLabelClick = (event: React.MouseEvent) => { + if (onClick && !hidden) { + onClick(name, value, event); + } + }; + // Using this component for labels and label values. If value is given use value for display text. + let text = value || name; + if (facets) { + text = `${text} (${facets})`; + } + + return ( + + ); + } +); + +Label.displayName = 'Label'; + +const getLabelStyles = (theme: GrafanaTheme2) => ({ + base: css` + cursor: pointer; + font-size: ${theme.typography.size.sm}; + line-height: ${theme.typography.bodySmall.lineHeight}; + background-color: ${theme.colors.background.secondary}; + vertical-align: baseline; + color: ${theme.colors.text}; + white-space: nowrap; + text-shadow: none; + padding: ${theme.spacing(0.5)}; + border-radius: ${theme.shape.borderRadius()}; + margin-right: ${theme.spacing(1)}; + margin-bottom: ${theme.spacing(0.5)}; + `, + loading: css` + font-weight: ${theme.typography.fontWeightMedium}; + background-color: ${theme.colors.primary.shade}; + color: ${theme.colors.text.primary}; + animation: pulse 3s ease-out 0s infinite normal forwards; + @keyframes pulse { + 0% { + color: ${theme.colors.text.primary}; + } + 50% { + color: ${theme.colors.text.secondary}; + } + 100% { + color: ${theme.colors.text.disabled}; + } + } + `, + active: css` + font-weight: ${theme.typography.fontWeightMedium}; + background-color: ${theme.colors.primary.main}; + color: ${theme.colors.primary.contrastText}; + `, + matchHighLight: css` + background: inherit; + color: ${theme.colors.primary.text}; + background-color: ${theme.colors.primary.transparent}; + `, + hidden: css` + opacity: 0.6; + cursor: default; + border: 1px solid transparent; + `, + hover: css` + &:hover { + opacity: 0.85; + cursor: pointer; + } + `, +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx new file mode 100644 index 0000000..a8d3e89 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { QueryEditorHelpProps, DataQuery } from '@grafana/data'; + +const CHEAT_SHEET_ITEMS = [ + { + title: 'Request Rate', + expression: 'rate(http_request_total[5m])', + label: + 'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.', + }, + { + title: '95th Percentile of Request Latencies', + expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))', + label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.', + }, + { + title: 'Alerts Firing', + expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))', + label: 'Sums up the alerts that have been firing over the last 24 hours.', + }, + { + title: 'Step', + label: + 'Defines the graph resolution using a duration format (15s, 1m, 3h, ...). Small steps create high-resolution graphs but can be slow over larger time ranges. Using a longer step lowers the resolution and smooths the graph by producing fewer datapoints. If no step is given the resolution is calculated automatically.', + }, +]; + +const PromCheatSheet = (props: QueryEditorHelpProps) => ( +
    +

    PromQL Cheat Sheet

    + {CHEAT_SHEET_ITEMS.map((item, index) => ( +
    +
    {item.title}
    + {item.expression ? ( +
    props.onClickExample({ refId: 'A', expr: item.expression } as DataQuery)} + > + {item.expression} +
    + ) : null} +
    {item.label}
    +
    + ))} +
    +); + +export default PromCheatSheet; diff --git a/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx b/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx new file mode 100644 index 0000000..645238a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx @@ -0,0 +1,66 @@ +import { GrafanaTheme } from '@grafana/data'; +import { IconButton, InlineLabel, Tooltip, useStyles } from '@grafana/ui'; +import { css, cx } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; +import { PrometheusDatasource } from '../datasource'; + +interface Props { + isEnabled: boolean; + onChange: (isEnabled: boolean) => void; + datasource: PrometheusDatasource; +} + +export function PromExemplarField({ datasource, onChange, isEnabled }: Props) { + const [error, setError] = useState(); + const styles = useStyles(getStyles); + + useEffect(() => { + const subscription = datasource.exemplarErrors.subscribe((err) => { + setError(err); + }); + return () => { + subscription.unsubscribe(); + }; + }, [datasource]); + + const iconButtonStyles = cx( + { + [styles.activeIcon]: isEnabled, + }, + styles.eyeIcon + ); + + return ( + + +
    + Exemplars + { + onChange(!isEnabled); + }} + /> +
    +
    +
    + ); +} + +function getStyles(theme: GrafanaTheme) { + return { + eyeIcon: css` + margin-left: ${theme.spacing.md}; + `, + activeIcon: css` + color: ${theme.palette.blue95}; + `, + iconWrapper: css` + display: flex; + align-items: center; + `, + }; +} diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx new file mode 100644 index 0000000..992b709 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PromExploreExtraFieldProps, PromExploreExtraField } from './PromExploreExtraField'; +import { Observable } from 'rxjs'; + +const setup = (propOverrides?: PromExploreExtraFieldProps) => { + const queryType = 'range'; + const stepValue = '1'; + const query = { exemplar: false }; + const datasource = { exemplarErrors: new Observable() }; + const onStepChange = jest.fn(); + const onQueryTypeChange = jest.fn(); + const onKeyDownFunc = jest.fn(); + + const props: any = { + queryType, + stepValue, + query, + onStepChange, + onQueryTypeChange, + onKeyDownFunc, + datasource, + }; + + Object.assign(props, propOverrides); + + return render(); +}; + +describe('PromExploreExtraField', () => { + it('should render step field', () => { + setup(); + expect(screen.getByTestId('stepField')).toBeInTheDocument(); + }); + + it('should render query type field', () => { + setup(); + expect(screen.getByTestId('queryTypeField')).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx new file mode 100644 index 0000000..283b96e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx @@ -0,0 +1,88 @@ +// Libraries +import React, { memo } from 'react'; +import { css, cx } from '@emotion/css'; + +// Types +import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui'; +import { PromQuery } from '../types'; +import { PromExemplarField } from './PromExemplarField'; +import { PrometheusDatasource } from '../datasource'; + +export interface PromExploreExtraFieldProps { + queryType: string; + stepValue: string; + query: PromQuery; + onStepChange: (e: React.SyntheticEvent) => void; + onKeyDownFunc: (e: React.KeyboardEvent) => void; + onQueryTypeChange: (value: string) => void; + onChange: (value: PromQuery) => void; + datasource: PrometheusDatasource; +} + +export const PromExploreExtraField: React.FC = memo( + ({ queryType, stepValue, query, onChange, onStepChange, onQueryTypeChange, onKeyDownFunc, datasource }) => { + const rangeOptions = [ + { value: 'range', label: 'Range', description: 'Run query over a range of time.' }, + { + value: 'instant', + label: 'Instant', + description: 'Run query against a single point in time. For this query, the "To" time is used.', + }, + { value: 'both', label: 'Both', description: 'Run an Instant query and a Range query.' }, + ]; + + return ( +
    + {/*Query type field*/} +
    + Query type + + +
    + {/*Step field*/} +
    + + Step + + +
    + + onChange({ ...query, exemplar: isEnabled })} + datasource={datasource} + /> +
    + ); + } +); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx new file mode 100644 index 0000000..ea45b58 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import PromExploreQueryEditor from './PromExploreQueryEditor'; +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; +import { LoadingState, PanelData, toUtc, TimeRange } from '@grafana/data'; + +const setup = (renderMethod: any, propOverrides?: object) => { + const datasourceMock: unknown = { + languageProvider: { + syntax: () => {}, + getLabelKeys: () => [], + metrics: [], + }, + }; + const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + const query: PromQuery = { expr: '', refId: 'A', interval: '1s' }; + const range: TimeRange = { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }; + const data: PanelData = { + state: LoadingState.NotStarted, + series: [], + request: { + requestId: '1', + dashboardId: 1, + intervalMs: 1000, + interval: '1s', + panelId: 1, + range: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }, + scopedVars: {}, + targets: [], + timezone: 'GMT', + app: 'Grafana', + startTime: 0, + }, + timeRange: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }, + }; + const history: any[] = []; + const exploreMode = 'Metrics'; + + const props: any = { + query, + data, + range, + datasource, + exploreMode, + history, + onChange, + onRunQuery, + }; + + Object.assign(props, propOverrides); + + return renderMethod(); +}; + +describe('PromExploreQueryEditor', () => { + let originalGetSelection: typeof window.getSelection; + beforeAll(() => { + originalGetSelection = window.getSelection; + window.getSelection = () => null; + }); + + afterAll(() => { + window.getSelection = originalGetSelection; + }); + + it('should render component', () => { + const wrapper = setup(shallow); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render PromQueryField with ExtraFieldElement', async () => { + // @ts-ignore strict null errpr TS2345: Argument of type '() => Promise' is not assignable to parameter of type '() => void | undefined'. + await act(async () => { + const wrapper = setup(shallow); + expect(wrapper.html()).toContain('aria-label="Prometheus extra field"'); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx new file mode 100644 index 0000000..3287560 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx @@ -0,0 +1,81 @@ +import React, { memo, FC, useEffect } from 'react'; + +// Types +import { ExploreQueryFieldProps } from '@grafana/data'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery, PromOptions } from '../types'; + +import PromQueryField from './PromQueryField'; +import { PromExploreExtraField } from './PromExploreExtraField'; + +export type Props = ExploreQueryFieldProps; + +export const PromExploreQueryEditor: FC = (props: Props) => { + const { range, query, data, datasource, history, onChange, onRunQuery } = props; + + useEffect(() => { + if (query.exemplar === undefined) { + onChange({ ...query, exemplar: true }); + } + }, [onChange, query]); + + function onChangeQueryStep(value: string) { + const { query, onChange } = props; + const nextQuery = { ...query, interval: value }; + onChange(nextQuery); + } + + function onStepChange(e: React.SyntheticEvent) { + if (e.currentTarget.value !== query.interval) { + onChangeQueryStep(e.currentTarget.value); + } + } + + function onReturnKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) { + onRunQuery(); + } + } + + function onQueryTypeChange(value: string) { + const { query, onChange } = props; + let nextQuery; + if (value === 'instant') { + nextQuery = { ...query, instant: true, range: false }; + } else if (value === 'range') { + nextQuery = { ...query, instant: false, range: true }; + } else { + nextQuery = { ...query, instant: true, range: true }; + } + onChange(nextQuery); + } + + return ( + {}} + history={history} + data={data} + ExtraFieldElement={ + + } + /> + ); +}; + +export default memo(PromExploreQueryEditor); diff --git a/public/app/plugins/datasource/prometheus/components/PromLink.test.tsx b/public/app/plugins/datasource/prometheus/components/PromLink.test.tsx new file mode 100644 index 0000000..1f05f8a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromLink.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PanelData } from '@grafana/data'; +import { PromQuery } from '../types'; +import { PrometheusDatasource } from '../datasource'; +import PromLink from './PromLink'; + +const getPanelData = (panelDataOverrides?: Partial) => { + const panelData = { + request: { + targets: [ + { refId: 'A', datasource: 'prom1' }, + { refId: 'B', datasource: 'prom2' }, + ], + range: { + to: { + utc: () => ({ + format: jest.fn(), + }), + }, + }, + }, + }; + + return Object.assign(panelData, panelDataOverrides) as PanelData; +}; + +const getDataSource = (datasourceOverrides?: Partial) => { + const datasource = { + getPrometheusTime: () => 123, + createQuery: () => ({ expr: 'up', step: 15 }), + directUrl: 'prom1', + }; + + return (Object.assign(datasource, datasourceOverrides) as unknown) as PrometheusDatasource; +}; + +describe('PromLink', () => { + it('should show correct link for 1 component', async () => { + render( +
    + +
    + ); + expect(screen.getByText('Prometheus')).toHaveAttribute( + 'href', + 'prom1/graph?g0.expr=up&g0.range_input=0s&g0.end_input=undefined&g0.step_input=15&g0.tab=0' + ); + }); + it('should show different link when there are 2 components with the same panel data', () => { + render( +
    + + +
    + ); + const promLinkButtons = screen.getAllByText('Prometheus'); + expect(promLinkButtons[0]).toHaveAttribute( + 'href', + 'prom1/graph?g0.expr=up&g0.range_input=0s&g0.end_input=undefined&g0.step_input=15&g0.tab=0' + ); + expect(promLinkButtons[1]).toHaveAttribute( + 'href', + 'prom2/graph?g0.expr=up&g0.range_input=0s&g0.end_input=undefined&g0.step_input=15&g0.tab=0' + ); + }); + it('should create sanitized link', async () => { + render( +
    + +
    + ); + expect(screen.getByText('Prometheus')).toHaveAttribute('href', 'about:blank'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromLink.tsx b/public/app/plugins/datasource/prometheus/components/PromLink.tsx new file mode 100644 index 0000000..abe22ea --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromLink.tsx @@ -0,0 +1,63 @@ +import { map } from 'lodash'; +import React, { FC, useEffect, useState, memo } from 'react'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; +import { DataQueryRequest, PanelData, textUtil } from '@grafana/data'; + +interface Props { + datasource: PrometheusDatasource; + query: PromQuery; + panelData?: PanelData; +} + +const PromLink: FC = ({ panelData, query, datasource }) => { + const [href, setHref] = useState(''); + + useEffect(() => { + if (panelData) { + const getExternalLink = () => { + if (!panelData.request) { + return ''; + } + + const { + request: { range, interval }, + } = panelData; + + const start = datasource.getPrometheusTime(range.from, false); + const end = datasource.getPrometheusTime(range.to, true); + const rangeDiff = Math.ceil(end - start); + const endTime = range.to.utc().format('YYYY-MM-DD HH:mm'); + + const options = { + interval, + } as DataQueryRequest; + + const queryOptions = datasource.createQuery(query, options, start, end); + const expr = { + 'g0.expr': queryOptions.expr, + 'g0.range_input': rangeDiff + 's', + 'g0.end_input': endTime, + 'g0.step_input': queryOptions.step, + 'g0.tab': 0, + }; + + const args = map(expr, (v: string, k: string) => { + return k + '=' + encodeURIComponent(v); + }).join('&'); + return `${datasource.directUrl}/graph?${args}`; + }; + + setHref(getExternalLink()); + } + }, [datasource, panelData, query]); + + return ( + + Prometheus + + ); +}; + +export default memo(PromLink); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx new file mode 100644 index 0000000..bfef578 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { dateTime } from '@grafana/data'; + +import { PromQueryEditor } from './PromQueryEditor'; +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; + +jest.mock('app/features/dashboard/services/TimeSrv', () => { + return { + getTimeSrv: () => ({ + timeRange: () => ({ + from: dateTime(), + to: dateTime(), + }), + }), + }; +}); + +const setup = (propOverrides?: object) => { + const datasourceMock: unknown = { + createQuery: jest.fn((q) => q), + getPrometheusTime: jest.fn((date, roundup) => 123), + }; + const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + const query: PromQuery = { expr: '', refId: 'A' }; + + const props: any = { + datasource, + onChange, + onRunQuery, + query, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as PromQueryEditor; + + return { + instance, + wrapper, + }; +}; + +describe('Render PromQueryEditor with basic options', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx new file mode 100644 index 0000000..ce08d37 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx @@ -0,0 +1,200 @@ +import { map } from 'lodash'; +import React, { PureComponent } from 'react'; + +// Types +import { InlineFormLabel, LegacyForms, Select } from '@grafana/ui'; +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { PrometheusDatasource } from '../datasource'; +import { PromOptions, PromQuery } from '../types'; + +import PromQueryField from './PromQueryField'; +import PromLink from './PromLink'; +import { PromExemplarField } from './PromExemplarField'; + +const { Switch } = LegacyForms; + +export type Props = QueryEditorProps; + +const FORMAT_OPTIONS: Array> = [ + { label: 'Time series', value: 'time_series' }, + { label: 'Table', value: 'table' }, + { label: 'Heatmap', value: 'heatmap' }, +]; + +const INTERVAL_FACTOR_OPTIONS: Array> = map([1, 2, 3, 4, 5, 10], (value: number) => ({ + value, + label: '1/' + value, +})); + +interface State { + legendFormat?: string; + formatOption: SelectableValue; + interval?: string; + intervalFactorOption: SelectableValue; + instant: boolean; + exemplar: boolean; +} + +export class PromQueryEditor extends PureComponent { + // Query target to be modified and used for queries + query: PromQuery; + + constructor(props: Props) { + super(props); + // Use default query to prevent undefined input values + const defaultQuery: Partial = { expr: '', legendFormat: '', interval: '', exemplar: true }; + const query = Object.assign({}, defaultQuery, props.query); + this.query = query; + // Query target properties that are fully controlled inputs + this.state = { + // Fully controlled text inputs + interval: query.interval, + legendFormat: query.legendFormat, + // Select options + formatOption: FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0], + intervalFactorOption: + INTERVAL_FACTOR_OPTIONS.find((option) => option.value === query.intervalFactor) || INTERVAL_FACTOR_OPTIONS[0], + // Switch options + instant: Boolean(query.instant), + exemplar: Boolean(query.exemplar), + }; + } + + onFieldChange = (query: PromQuery, override?: any) => { + this.query.expr = query.expr; + }; + + onFormatChange = (option: SelectableValue) => { + this.query.format = option.value; + this.setState({ formatOption: option }, this.onRunQuery); + }; + + onInstantChange = (e: React.SyntheticEvent) => { + const instant = (e.target as HTMLInputElement).checked; + this.query.instant = instant; + this.setState({ instant }, this.onRunQuery); + }; + + onIntervalChange = (e: React.SyntheticEvent) => { + const interval = e.currentTarget.value; + this.query.interval = interval; + this.setState({ interval }); + }; + + onIntervalFactorChange = (option: SelectableValue) => { + this.query.intervalFactor = option.value; + this.setState({ intervalFactorOption: option }, this.onRunQuery); + }; + + onLegendChange = (e: React.SyntheticEvent) => { + const legendFormat = e.currentTarget.value; + this.query.legendFormat = legendFormat; + this.setState({ legendFormat }); + }; + + onExemplarChange = (isEnabled: boolean) => { + this.query.exemplar = isEnabled; + this.setState({ exemplar: isEnabled }, this.onRunQuery); + }; + + onRunQuery = () => { + const { query } = this; + // Change of query.hide happens outside of this component and is just passed as prop. We have to update it when running queries. + const { hide } = this.props.query; + this.props.onChange({ ...query, hide }); + this.props.onRunQuery(); + }; + + render() { + const { datasource, query, range, data } = this.props; + const { formatOption, instant, interval, intervalFactorOption, legendFormat, exemplar } = this.state; + + return ( + +
    + + Legend + + +
    + +
    + + An additional lower limit for the step parameter of the Prometheus query and for the{' '} + $__interval and $__rate_interval variables. The limit is absolute and not + modified by the "Resolution" setting. + + } + > + Min step + + +
    + +
    +
    Resolution
    + + + + + + +
    + + + + } + /> + ); + } +} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx new file mode 100644 index 0000000..f05bf57 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx @@ -0,0 +1,112 @@ +// @ts-ignore +import RCCascader from 'rc-cascader'; +import React from 'react'; +import PromQlLanguageProvider from '../language_provider'; +import PromQueryField from './PromQueryField'; +import { DataSourceInstanceSettings } from '@grafana/data'; +import { PromOptions } from '../types'; +import { render, screen } from '@testing-library/react'; + +describe('PromQueryField', () => { + beforeAll(() => { + // @ts-ignore + window.getSelection = () => {}; + }); + + it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', () => { + const datasource = ({ + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + getLabelKeys: () => [], + metrics: [], + }, + } as unknown) as DataSourceInstanceSettings; + + const queryField = render( + {}} + onChange={() => {}} + history={[]} + /> + ); + + expect(queryField.getAllByRole('button')).toHaveLength(1); + }); + + it('renders a disabled metrics chooser if lookups are disabled in datasource settings', () => { + const datasource = ({ + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + getLabelKeys: () => [], + metrics: [], + }, + } as unknown) as DataSourceInstanceSettings; + const queryField = render( + {}} + onChange={() => {}} + history={[]} + /> + ); + + const bcButton = queryField.getByRole('button'); + expect(bcButton).toBeDisabled(); + }); + + it('refreshes metrics when the data source changes', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + const metrics = ['foo', 'bar']; + const queryField = render( + + ); + + const changedMetrics = ['baz', 'moo']; + queryField.rerender( + + ); + + // If we check the label browser right away it should be in loading state + let labelBrowser = screen.getByRole('button'); + expect(labelBrowser.textContent).toContain('Loading'); + }); +}); + +function makeLanguageProvider(options: { metrics: string[][] }) { + const metricsStack = [...options.metrics]; + return ({ + histogramMetrics: [] as any, + metrics: [], + metricsMetadata: {}, + lookupsDisabled: false, + getLabelKeys: () => [], + start() { + this.metrics = metricsStack.shift(); + return Promise.resolve([]); + }, + } as any) as PromQlLanguageProvider; +} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx new file mode 100644 index 0000000..3b4da65 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -0,0 +1,333 @@ +import React, { ReactNode } from 'react'; + +import { Plugin } from 'slate'; +import { + SlatePrism, + TypeaheadInput, + TypeaheadOutput, + QueryField, + BracesPlugin, + DOMUtil, + SuggestionsState, + Icon, +} from '@grafana/ui'; + +import { LanguageMap, languages as prismLanguages } from 'prismjs'; + +// dom also includes Element polyfills +import { PromQuery, PromOptions } from '../types'; +import { roundMsToMin } from '../language_utils'; +import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; +import { + ExploreQueryFieldProps, + QueryHint, + isDataFrame, + toLegacyResponseData, + HistoryItem, + TimeRange, +} from '@grafana/data'; +import { PrometheusDatasource } from '../datasource'; +import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser'; + +export const RECORDING_RULES_GROUP = '__recording_rules__'; + +function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) { + if (metricsLookupDisabled) { + return '(Disabled)'; + } + + if (!hasSyntax) { + return 'Loading metrics...'; + } + + if (!hasMetrics) { + return '(No metrics found)'; + } + + return 'Metrics browser'; +} + +export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { + // Modify suggestion based on context + switch (typeaheadContext) { + case 'context-labels': { + const nextChar = DOMUtil.getNextCharacter(); + if (!nextChar || nextChar === '}' || nextChar === ',') { + suggestion += '='; + } + break; + } + + case 'context-label-values': { + // Always add quotes and remove existing ones instead + if (!typeaheadText.match(/^(!?=~?"|")/)) { + suggestion = `"${suggestion}`; + } + if (DOMUtil.getNextCharacter() !== '"') { + suggestion = `${suggestion}"`; + } + break; + } + + default: + } + return suggestion; +} + +interface PromQueryFieldProps extends ExploreQueryFieldProps { + history: Array>; + ExtraFieldElement?: ReactNode; +} + +interface PromQueryFieldState { + labelBrowserVisible: boolean; + syntaxLoaded: boolean; + hint: QueryHint | null; +} + +class PromQueryField extends React.PureComponent { + plugins: Plugin[]; + languageProviderInitializationPromise: CancelablePromise; + + constructor(props: PromQueryFieldProps, context: React.Context) { + super(props, context); + + this.plugins = [ + BracesPlugin(), + SlatePrism( + { + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: (node: any) => 'promql', + }, + { ...(prismLanguages as LanguageMap), promql: this.props.datasource.languageProvider.syntax } + ), + ]; + + this.state = { + labelBrowserVisible: false, + syntaxLoaded: false, + hint: null, + }; + } + + componentDidMount() { + if (this.props.datasource.languageProvider) { + this.refreshMetrics(); + } + this.refreshHint(); + } + + componentWillUnmount() { + if (this.languageProviderInitializationPromise) { + this.languageProviderInitializationPromise.cancel(); + } + } + + componentDidUpdate(prevProps: PromQueryFieldProps) { + const { + data, + datasource: { languageProvider }, + range, + } = this.props; + + if (languageProvider !== prevProps.datasource.languageProvider) { + // We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every + // query run if using relative range. + this.setState({ + syntaxLoaded: false, + }); + } + + const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range); + // We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute) + if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) { + this.refreshMetrics(); + } + + if (data && prevProps.data && prevProps.data.series !== data.series) { + this.refreshHint(); + } + } + + refreshHint = () => { + const { datasource, query, data } = this.props; + + if (!data || data.series.length === 0) { + this.setState({ hint: null }); + return; + } + + const result = isDataFrame(data.series[0]) ? data.series.map(toLegacyResponseData) : data.series; + const hints = datasource.getQueryHints(query, result); + let hint = hints.length > 0 ? hints[0] : null; + + // Hint for big disabled lookups + if (!hint && datasource.lookupsDisabled) { + hint = { + label: `Labels and metrics lookup was disabled in data source settings.`, + type: 'INFO', + }; + } + this.setState({ hint }); + }; + + refreshMetrics = async () => { + const { + datasource: { languageProvider }, + } = this.props; + + this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start()); + + try { + const remainingTasks = await this.languageProviderInitializationPromise.promise; + await Promise.all(remainingTasks); + this.onUpdateLanguage(); + } catch (err) { + if (!err.isCanceled) { + throw err; + } + } + }; + + rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean { + if (range && prevRange) { + const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf()); + const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf()); + // If both are same, don't need to refresh. + return !(sameMinuteFrom && sameMinuteTo); + } + return false; + } + + /** + * TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;) + */ + onChangeLabelBrowser = (selector: string) => { + this.onChangeQuery(selector, true); + this.setState({ labelBrowserVisible: false }); + }; + + onChangeQuery = (value: string, override?: boolean) => { + // Send text change to parent + const { query, onChange, onRunQuery } = this.props; + if (onChange) { + const nextQuery: PromQuery = { ...query, expr: value }; + onChange(nextQuery); + + if (override && onRunQuery) { + onRunQuery(); + } + } + }; + + onClickChooserButton = () => { + this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible })); + }; + + onClickHintFix = () => { + const { datasource, query, onChange, onRunQuery } = this.props; + const { hint } = this.state; + + onChange(datasource.modifyQuery(query, hint!.fix!.action)); + onRunQuery(); + }; + + onUpdateLanguage = () => { + const { + datasource: { languageProvider }, + } = this.props; + const { metrics } = languageProvider; + + if (!metrics) { + return; + } + + this.setState({ syntaxLoaded: true }); + }; + + onTypeahead = async (typeahead: TypeaheadInput): Promise => { + const { + datasource: { languageProvider }, + } = this.props; + + if (!languageProvider) { + return { suggestions: [] }; + } + + const { history } = this.props; + const { prefix, text, value, wrapperClasses, labelKey } = typeahead; + + const result = await languageProvider.provideCompletionItems( + { text, value, prefix, wrapperClasses, labelKey }, + { history } + ); + + return result; + }; + + render() { + const { + datasource, + datasource: { languageProvider }, + query, + ExtraFieldElement, + } = this.props; + const { labelBrowserVisible, syntaxLoaded, hint } = this.state; + const cleanText = languageProvider ? languageProvider.cleanText : undefined; + const hasMetrics = languageProvider.metrics.length > 0; + const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); + const buttonDisabled = !(syntaxLoaded && hasMetrics); + + return ( + <> +
    + + +
    + +
    +
    + {labelBrowserVisible && ( +
    + +
    + )} + + {ExtraFieldElement} + {hint ? ( +
    +
    + {hint.label}{' '} + {hint.fix ? ( + + {hint.fix.label} + + ) : null} +
    +
    + ) : null} + + ); + } +} + +export default PromQueryField; diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx new file mode 100644 index 0000000..bb84246 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getTheme } from '@grafana/ui'; +import { + buildSelector, + facetLabels, + SelectableLabel, + UnthemedPrometheusMetricsBrowser, + BrowserProps, +} from './PrometheusMetricsBrowser'; +import PromQlLanguageProvider from '../language_provider'; + +describe('buildSelector()', () => { + it('returns an empty selector for no labels', () => { + expect(buildSelector([])).toEqual('{}'); + }); + it('returns an empty selector for selected labels with no values', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true }]; + expect(buildSelector(labels)).toEqual('{}'); + }); + it('returns an empty selector for one selected label with no selected values', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }]; + expect(buildSelector(labels)).toEqual('{}'); + }); + it('returns a simple selector from a selected label with a selected value', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }]; + expect(buildSelector(labels)).toEqual('{foo="bar"}'); + }); + it('metric selector without labels', () => { + const labels: SelectableLabel[] = [{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }]; + expect(buildSelector(labels)).toEqual('foo{}'); + }); + it('selector with multiple metrics', () => { + const labels: SelectableLabel[] = [ + { + name: '__name__', + selected: true, + values: [ + { name: 'foo', selected: true }, + { name: 'bar', selected: true }, + ], + }, + ]; + expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}'); + }); + it('metric selector with labels', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, + { name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('foo{bar="baz"}'); + }); +}); + +describe('facetLabels()', () => { + const possibleLabels = { + cluster: ['dev'], + namespace: ['alertmanager'], + }; + const labels: SelectableLabel[] = [ + { name: 'foo', selected: true, values: [{ name: 'bar' }] }, + { name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] }, + { name: 'namespace', values: [{ name: 'alertmanager' }] }, + ]; + + it('returns no labels given an empty label set', () => { + expect(facetLabels([], {})).toEqual([]); + }); + + it('marks all labels as hidden when no labels are possible', () => { + const result = facetLabels(labels, {}); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[0].values).toBeUndefined(); + }); + + it('keeps values as facetted when they are possible', () => { + const result = facetLabels(labels, possibleLabels); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[0].values).toBeUndefined(); + expect(result[1].hidden).toBeFalsy(); + expect(result[1].values!.length).toBe(1); + expect(result[1].values![0].name).toBe('dev'); + }); + + it('does not facet out label values that are currently being facetted', () => { + const result = facetLabels(labels, possibleLabels, 'cluster'); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[1].hidden).toBeFalsy(); + // 'cluster' is being facetted, should show all 3 options even though only 1 is possible + expect(result[1].values!.length).toBe(3); + expect(result[2].values!.length).toBe(1); + }); +}); + +describe('PrometheusMetricsBrowser', () => { + const setupProps = (): BrowserProps => { + const mockLanguageProvider = { + start: () => Promise.resolve(), + getLabelValues: (name: string) => { + switch (name) { + case 'label1': + return ['value1-1', 'value1-2']; + case 'label2': + return ['value2-1', 'value2-2']; + case 'label3': + return ['value3-1', 'value3-2']; + } + return []; + }, + fetchSeriesLabels: (selector: string) => { + switch (selector) { + case '{label1="value1-1"}': + return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; + case '{label1=~"value1-1|value1-2"}': + return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] }; + } + // Allow full set by default + return { + label1: ['value1-1', 'value1-2'], + label2: ['value2-1', 'value2-2'], + }; + }, + getLabelKeys: () => ['label1', 'label2', 'label3'], + }; + + const defaults: BrowserProps = { + theme: getTheme(), + onChange: () => {}, + autoSelect: 0, + languageProvider: (mockLanguageProvider as unknown) as PromQlLanguageProvider, + }; + + return defaults; + }; + + // Clear label selection manually because it's saved in localStorage + afterEach(() => { + const clearBtn = screen.getByLabelText('Selector clear button'); + userEvent.click(clearBtn); + }); + + it('renders and loader shows when empty, and then first set of labels', async () => { + const props = setupProps(); + render(); + // Loading appears and dissappears + screen.getByText(/Loading labels/); + await waitFor(() => { + expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument(); + }); + // Initial set of labels is available and not selected + expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + }); + + it('allows label and value selection/deselection', async () => { + const props = setupProps(); + render(); + // Selecting label2 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + expect(screen.queryByRole('list', { name: /Values/ })).not.toBeInTheDocument(); + userEvent.click(label2); + expect(screen.queryByRole('option', { name: /label2/, selected: true })).toBeInTheDocument(); + // List of values for label2 appears + expect(await screen.findAllByRole('list')).toHaveLength(1); + expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); + expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + // Selecting label1, list for its values appears + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + userEvent.click(label1); + expect(screen.queryByRole('option', { name: /label1/, selected: true })).toBeInTheDocument(); + await screen.findByLabelText('Values for label1'); + expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2); + // Selecting value2-2 of label2 + const value = await screen.findByRole('option', { name: 'value2-2', selected: false }); + userEvent.click(value); + await screen.findByRole('option', { name: 'value2-2', selected: true }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}'); + // Selecting value2-1 of label2, both values now selected + const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); + userEvent.click(value2); + // await screen.findByRole('option', {name: 'value2-1', selected: true}); + await screen.findByText('{label2=~"value2-1|value2-2"}'); + // Deselecting value2-2, one value should remain + const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true }); + userEvent.click(selectedValue); + await screen.findByRole('option', { name: 'value2-1', selected: true }); + await screen.findByRole('option', { name: 'value2-2', selected: false }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); + // Selecting value from label1 for combined selector + const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false }); + userEvent.click(value1); + await screen.findByRole('option', { name: 'value1-2', selected: true }); + await screen.findByText('{label1="value1-2",label2="value2-1"}'); + // Deselect label1 should remove label and value + const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0]; + userEvent.click(selectedLabel); + await screen.findByRole('option', { name: /label1/, selected: false }); + expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(1); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); + // Clear selector + const clearBtn = screen.getByLabelText('Selector clear button'); + userEvent.click(clearBtn); + await screen.findByRole('option', { name: /label2/, selected: false }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + }); + + it('filters values by input text', async () => { + const props = setupProps(); + render(); + // Selecting label2 and label1 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + userEvent.click(label2); + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + await screen.findByLabelText('Values for label2'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); + // Typing '1' to filter for values + userEvent.type(screen.getByLabelText('Filter expression for label values'), '1'); + expect(screen.getByLabelText('Filter expression for label values')).toHaveValue('1'); + expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument(); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3); + }); + + it('facets labels', async () => { + const props = setupProps(); + render(); + // Selecting label2 and label1 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + userEvent.click(label2); + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + await screen.findByLabelText('Values for label2'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); + expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3'); + // Click value1-1 which triggers facetting for value3-x, and still show all value1-x + const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false }); + userEvent.click(value1); + await waitForElementToBeRemoved(screen.queryByRole('option', { name: 'value2-2' })); + expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}'); + expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)'); + // Click value1-2 for which facetting will allow more values for value3-x + const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false }); + userEvent.click(value12); + await screen.findByRole('option', { name: 'value1-2', selected: true }); + userEvent.click(screen.getByRole('option', { name: /label3/ })); + await screen.findByLabelText('Values for label3'); + expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}'); + expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx new file mode 100644 index 0000000..f6b83a5 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx @@ -0,0 +1,633 @@ +import React, { ChangeEvent } from 'react'; +import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui'; +import PromQlLanguageProvider from '../language_provider'; +import { css, cx } from '@emotion/css'; +import store from 'app/core/store'; +import { FixedSizeList } from 'react-window'; + +import { GrafanaTheme } from '@grafana/data'; +import { Label as PromLabel } from './Label'; + +// Hard limit on labels to render +const MAX_LABEL_COUNT = 10000; +const MAX_VALUE_COUNT = 10000; +const EMPTY_SELECTOR = '{}'; +const METRIC_LABEL = '__name__'; +export const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels'; + +export interface BrowserProps { + languageProvider: PromQlLanguageProvider; + onChange: (selector: string) => void; + theme: GrafanaTheme; + autoSelect?: number; + hide?: () => void; +} + +interface BrowserState { + labels: SelectableLabel[]; + labelSearchTerm: string; + metricSearchTerm: string; + status: string; + error: string; + validationStatus: string; + valueSearchTerm: string; +} + +interface FacettableValue { + name: string; + selected?: boolean; +} + +export interface SelectableLabel { + name: string; + selected?: boolean; + loading?: boolean; + values?: FacettableValue[]; + hidden?: boolean; + facets?: number; +} + +export function buildSelector(labels: SelectableLabel[]): string { + let singleMetric = ''; + const selectedLabels = []; + for (const label of labels) { + if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) { + const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name); + if (selectedValues.length > 1) { + selectedLabels.push(`${label.name}=~"${selectedValues.join('|')}"`); + } else if (selectedValues.length === 1) { + if (label.name === METRIC_LABEL) { + singleMetric = selectedValues[0]; + } else { + selectedLabels.push(`${label.name}="${selectedValues[0]}"`); + } + } + } + } + return [singleMetric, '{', selectedLabels.join(','), '}'].join(''); +} + +export function facetLabels( + labels: SelectableLabel[], + possibleLabels: Record, + lastFacetted?: string +): SelectableLabel[] { + return labels.map((label) => { + const possibleValues = possibleLabels[label.name]; + if (possibleValues) { + let existingValues: FacettableValue[]; + if (label.name === lastFacetted && label.values) { + // Facetting this label, show all values + existingValues = label.values; + } else { + // Keep selection in other facets + const selectedValues: Set = new Set( + label.values?.filter((value) => value.selected).map((value) => value.name) || [] + ); + // Values for this label have not been requested yet, let's use the facetted ones as the initial values + existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) })); + } + return { + ...label, + loading: false, + values: existingValues, + hidden: !possibleValues, + facets: existingValues.length, + }; + } + + // Label is facetted out, hide all values + return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 }; + }); +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + wrapper: css` + background-color: ${theme.colors.bg2}; + padding: ${theme.spacing.md}; + width: 100%; + `, + list: css` + margin-top: ${theme.spacing.sm}; + display: flex; + flex-wrap: wrap; + max-height: 200px; + overflow: auto; + `, + section: css` + & + & { + margin: ${theme.spacing.md} 0; + } + position: relative; + `, + selector: css` + font-family: ${theme.typography.fontFamily.monospace}; + margin-bottom: ${theme.spacing.sm}; + `, + status: css` + padding: ${theme.spacing.xs}; + color: ${theme.colors.textSemiWeak}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* using absolute positioning because flex interferes with ellipsis */ + position: absolute; + width: 50%; + right: 0; + text-align: right; + transition: opacity 100ms linear; + opacity: 0; + `, + statusShowing: css` + opacity: 1; + `, + error: css` + color: ${theme.palette.brandDanger}; + `, + valueList: css` + margin-right: ${theme.spacing.sm}; + `, + valueListWrapper: css` + border-left: 1px solid ${theme.colors.border2}; + margin: ${theme.spacing.sm} 0; + padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm}; + `, + valueListArea: css` + display: flex; + flex-wrap: wrap; + margin-top: ${theme.spacing.sm}; + `, + valueTitle: css` + margin-left: -${theme.spacing.xs}; + margin-bottom: ${theme.spacing.sm}; + `, + validationStatus: css` + padding: ${theme.spacing.xs}; + margin-bottom: ${theme.spacing.sm}; + color: ${theme.colors.textStrong}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `, +})); + +/** + * TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible + * to create a single, generic component. + */ +export class UnthemedPrometheusMetricsBrowser extends React.Component { + state = { + labels: [] as SelectableLabel[], + labelSearchTerm: '', + metricSearchTerm: '', + status: 'Ready', + error: '', + validationStatus: '', + valueSearchTerm: '', + }; + + onChangeLabelSearch = (event: ChangeEvent) => { + this.setState({ labelSearchTerm: event.target.value }); + }; + + onChangeMetricSearch = (event: ChangeEvent) => { + this.setState({ metricSearchTerm: event.target.value }); + }; + + onChangeValueSearch = (event: ChangeEvent) => { + this.setState({ valueSearchTerm: event.target.value }); + }; + + onClickRunQuery = () => { + const selector = buildSelector(this.state.labels); + this.props.onChange(selector); + }; + + onClickRunRateQuery = () => { + const selector = buildSelector(this.state.labels); + const query = `rate(${selector}[$__interval])`; + this.props.onChange(query); + }; + + onClickClear = () => { + this.setState((state) => { + const labels: SelectableLabel[] = state.labels.map((label) => ({ + ...label, + values: undefined, + selected: false, + loading: false, + hidden: false, + facets: undefined, + })); + return { + labels, + labelSearchTerm: '', + metricSearchTerm: '', + status: '', + error: '', + validationStatus: '', + valueSearchTerm: '', + }; + }); + store.delete(LAST_USED_LABELS_KEY); + // Get metrics + this.fetchValues(METRIC_LABEL); + }; + + onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + // Toggle selected state + const selected = !label.selected; + let nextValue: Partial = { selected }; + if (label.values && !selected) { + // Deselect all values if label was deselected + const values = label.values.map((value) => ({ ...value, selected: false })); + nextValue = { ...nextValue, facets: 0, values }; + } + // Resetting search to prevent empty results + this.setState({ labelSearchTerm: '' }); + this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name)); + }; + + onClickValue = (name: string, value: string | undefined, event: React.MouseEvent) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label || !label.values) { + return; + } + // Resetting search to prevent empty results + this.setState({ labelSearchTerm: '' }); + // Toggling value for selected label, leaving other values intact + const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected })); + this.updateLabelState(name, { values }, '', () => this.doFacetting(name)); + }; + + onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent) => { + // Finding special metric label + const label = this.state.labels.find((l) => l.name === name); + if (!label || !label.values) { + return; + } + // Resetting search to prevent empty results + this.setState({ metricSearchTerm: '' }); + // Toggling value for selected label, leaving other values intact + const values = label.values.map((v) => ({ + ...v, + selected: v.name === value || v.selected ? !v.selected : v.selected, + })); + // Toggle selected state of special metrics label + const selected = values.some((v) => v.selected); + this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name)); + }; + + onClickValidate = () => { + const selector = buildSelector(this.state.labels); + this.validateSelector(selector); + }; + + updateLabelState(name: string, updatedFields: Partial, status = '', cb?: () => void) { + this.setState((state) => { + const labels: SelectableLabel[] = state.labels.map((label) => { + if (label.name === name) { + return { ...label, ...updatedFields }; + } + return label; + }); + // New status overrides errors + const error = status ? '' : state.error; + return { labels, status, error, validationStatus: '' }; + }, cb); + } + + componentDidMount() { + const { languageProvider } = this.props; + if (languageProvider) { + const selectedLabels: string[] = store.getObject(LAST_USED_LABELS_KEY, []); + languageProvider.start().then(() => { + let rawLabels: string[] = languageProvider.getLabelKeys(); + // TODO too-many-metrics + if (rawLabels.length > MAX_LABEL_COUNT) { + const error = `Too many labels found (showing only ${MAX_LABEL_COUNT} of ${rawLabels.length})`; + rawLabels = rawLabels.slice(0, MAX_LABEL_COUNT); + this.setState({ error }); + } + // Get metrics + this.fetchValues(METRIC_LABEL); + // Auto-select previously selected labels + const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({ + name: label, + selected: selectedLabels.includes(label), + loading: false, + })); + // Pre-fetch values for selected labels + this.setState({ labels }, () => { + this.state.labels.forEach((label) => { + if (label.selected) { + this.fetchValues(label.name); + } + }); + }); + }); + } + } + + doFacettingForLabel(name: string) { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name); + store.setObject(LAST_USED_LABELS_KEY, selectedLabels); + if (label.selected) { + // Refetch values for newly selected label... + if (!label.values) { + this.fetchValues(name); + } + } else { + // Only need to facet when deselecting labels + this.doFacetting(); + } + } + + doFacetting = (lastFacetted?: string) => { + const selector = buildSelector(this.state.labels); + if (selector === EMPTY_SELECTOR) { + // Clear up facetting + const labels: SelectableLabel[] = this.state.labels.map((label) => { + return { ...label, facets: 0, values: undefined, hidden: false }; + }); + this.setState({ labels }, () => { + // Get fresh set of values + this.state.labels.forEach( + (label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name) + ); + }); + } else { + // Do facetting + this.fetchSeries(selector, lastFacetted); + } + }; + + async fetchValues(name: string) { + const { languageProvider } = this.props; + this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); + try { + let rawValues = await languageProvider.getLabelValues(name); + if (rawValues.length > MAX_VALUE_COUNT) { + const error = `Too many values for ${name} (showing only ${MAX_VALUE_COUNT} of ${rawValues.length})`; + rawValues = rawValues.slice(0, MAX_VALUE_COUNT); + this.setState({ error }); + } + const values: FacettableValue[] = rawValues.map((value) => ({ name: value })); + this.updateLabelState(name, { values, loading: false }, ''); + } catch (error) { + console.error(error); + } + } + + async fetchSeries(selector: string, lastFacetted?: string) { + const { languageProvider } = this.props; + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`); + } + try { + const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true); + if (Object.keys(possibleLabels).length === 0) { + // Sometimes the backend does not return a valid set + console.error('No results for label combination, but should not occur.'); + this.setState({ error: `Facetting failed for ${selector}` }); + return; + } + const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted); + this.setState({ labels, error: '' }); + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: false }); + } + } catch (error) { + console.error(error); + } + } + + async validateSelector(selector: string) { + const { languageProvider } = this.props; + this.setState({ validationStatus: `Validating selector ${selector}`, error: '' }); + const streams = await languageProvider.fetchSeries(selector); + this.setState({ validationStatus: `Selector is valid (${streams.length} streams found)` }); + } + + render() { + const { theme } = this.props; + const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state; + const styles = getStyles(theme); + if (labels.length === 0) { + return ( +
    + +
    + ); + } + + // Filter metrics + let metrics = labels.find((label) => label.name === METRIC_LABEL); + if (metrics && metricSearchTerm) { + // TODO extract from render() and debounce + metrics = { + ...metrics, + values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)), + }; + } + + // Filter labels + let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL); + if (labelSearchTerm) { + // TODO extract from render() and debounce + nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm)); + } + + // Filter non-metric label values + let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values); + if (valueSearchTerm) { + // TODO extract from render() and debounce + selectedLabels = selectedLabels.map((label) => ({ + ...label, + values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)), + })); + } + const selector = buildSelector(this.state.labels); + const empty = selector === EMPTY_SELECTOR; + return ( +
    + +
    +
    + +
    + +
    +
    + (metrics!.values as FacettableValue[])[i].name} + width={300} + className={styles.valueList} + > + {({ index, style }) => { + const value = metrics?.values?.[index]; + if (!value) { + return null; + } + return ( +
    + +
    + ); + }} +
    +
    +
    +
    + +
    +
    + +
    + +
    +
    + {nonMetricLabels.map((label) => ( +
    +
    +
    + +
    + +
    +
    + {selectedLabels.map((label) => ( +
    +
    +
    + (label.values as FacettableValue[])[i].name} + width={200} + className={styles.valueList} + > + {({ index, style }) => { + const value = label.values?.[index]; + if (!value) { + return null; + } + return ( +
    + +
    + ); + }} +
    +
    + ))} +
    +
    +
    +
    + +
    + +
    + {selector} +
    + {validationStatus &&
    {validationStatus}
    } + + + + + +
    + {error || status} +
    +
    +
    +
    + ); + } +} + +export const PrometheusMetricsBrowser = withTheme(UnthemedPrometheusMetricsBrowser); diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap new file mode 100644 index 0000000..9f69a2c --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromExploreQueryEditor should render component 1`] = ` + + } + data={ + Object { + "request": Object { + "app": "Grafana", + "dashboardId": 1, + "interval": "1s", + "intervalMs": 1000, + "panelId": 1, + "range": Object { + "from": "2020-01-01T00:00:00.000Z", + "raw": Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + }, + "to": "2020-01-02T00:00:00.000Z", + }, + "requestId": "1", + "scopedVars": Object {}, + "startTime": 0, + "targets": Array [], + "timezone": "GMT", + }, + "series": Array [], + "state": "NotStarted", + "timeRange": Object { + "from": "2020-01-01T00:00:00.000Z", + "raw": Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + }, + "to": "2020-01-02T00:00:00.000Z", + }, + } + } + datasource={ + Object { + "languageProvider": Object { + "getLabelKeys": [Function], + "metrics": Array [], + "syntax": [Function], + }, + } + } + history={Array []} + onBlur={[Function]} + onChange={[MockFunction]} + onRunQuery={[MockFunction]} + query={ + Object { + "expr": "", + "interval": "1s", + "refId": "A", + } + } + range={ + Object { + "from": "2020-01-01T00:00:00.000Z", + "raw": Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + }, + "to": "2020-01-02T00:00:00.000Z", + } + } +/> +`; diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap new file mode 100644 index 0000000..a3f1dff --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap @@ -0,0 +1,197 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render PromQueryEditor with basic options should render 1`] = ` + +
    + + Legend + + +
    +
    + + An additional lower limit for the step parameter of the Prometheus query and for the + + + $__interval + + and + + $__rate_interval + + variables. The limit is absolute and not modified by the "Resolution" setting. + + } + width={7} + > + Min step + + +
    +
    +
    + Resolution +
    + + + + + +
    + + + } + datasource={ + Object { + "createQuery": [MockFunction], + "getPrometheusTime": [MockFunction], + } + } + history={Array []} + onChange={[Function]} + onRunQuery={[Function]} + query={ + Object { + "expr": "", + "refId": "A", + } + } +/> +`; diff --git a/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx new file mode 100644 index 0000000..93b77ce --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { PromSettings } from './PromSettings'; +import { PromOptions } from '../types'; +import { config } from 'app/core/config'; + +export type Props = DataSourcePluginOptionsEditorProps; +export const ConfigEditor = (props: Props) => { + const { options, onOptionsChange } = props; + return ( + <> + + + + + ); +}; diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx new file mode 100644 index 0000000..8ca4704 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx @@ -0,0 +1,98 @@ +import { Button, InlineField, InlineSwitch, Input } from '@grafana/ui'; +import { DataSourcePicker } from '@grafana/runtime'; +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import { ExemplarTraceIdDestination } from '../types'; + +type Props = { + value: ExemplarTraceIdDestination; + onChange: (value: ExemplarTraceIdDestination) => void; + onDelete: () => void; +}; + +export default function ExemplarSetting({ value, onChange, onDelete }: Props) { + const [isInternalLink, setIsInternalLink] = useState(Boolean(value.datasourceUid)); + + return ( +
    + + <> + setIsInternalLink(ev.currentTarget.checked)} /> +
    + ); +} diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx new file mode 100644 index 0000000..9470666 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx @@ -0,0 +1,53 @@ +import { Button } from '@grafana/ui'; +import { css } from '@emotion/css'; +import React from 'react'; +import { ExemplarTraceIdDestination } from '../types'; +import ExemplarSetting from './ExemplarSetting'; + +type Props = { + options?: ExemplarTraceIdDestination[]; + onChange: (value: ExemplarTraceIdDestination[]) => void; +}; + +export function ExemplarsSettings({ options, onChange }: Props) { + return ( + <> +

    Exemplars

    + + {options && + options.map((option, index) => { + return ( + { + const newOptions = [...options]; + newOptions.splice(index, 1, newField); + onChange(newOptions); + }} + onDelete={() => { + const newOptions = [...options]; + newOptions.splice(index, 1); + onChange(newOptions); + }} + /> + ); + })} + + + + ); +} diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx new file mode 100644 index 0000000..ae42697 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx @@ -0,0 +1,140 @@ +import React, { SyntheticEvent } from 'react'; +import { render, screen } from '@testing-library/react'; +import { EventsWithValidation } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { getValueFromEventItem, promSettingsValidationEvents, PromSettings } from './PromSettings'; +import { createDefaultConfigOptions } from './mocks'; + +describe('PromSettings', () => { + describe('getValueFromEventItem', () => { + describe('when called with undefined', () => { + it('then it should return empty string', () => { + const result = getValueFromEventItem( + (undefined as unknown) as SyntheticEvent | SelectableValue + ); + expect(result).toEqual(''); + }); + }); + + describe('when called with an input event', () => { + it('then it should return value from currentTarget', () => { + const value = 'An input value'; + const result = getValueFromEventItem({ currentTarget: { value } }); + expect(result).toEqual(value); + }); + }); + + describe('when called with a select event', () => { + it('then it should return value', () => { + const value = 'A select value'; + const result = getValueFromEventItem({ value }); + expect(result).toEqual(value); + }); + }); + }); + + describe('promSettingsValidationEvents', () => { + const validationEvents = promSettingsValidationEvents; + + it('should have one event handlers', () => { + expect(Object.keys(validationEvents).length).toEqual(1); + }); + + it('should have an onBlur handler', () => { + expect(validationEvents.hasOwnProperty(EventsWithValidation.onBlur)).toBe(true); + }); + + it('should have one rule', () => { + expect(validationEvents[EventsWithValidation.onBlur].length).toEqual(1); + }); + + describe('when calling the rule with an empty string', () => { + it('then it should return true', () => { + expect(validationEvents[EventsWithValidation.onBlur][0].rule('')).toBe(true); + }); + }); + + it.each` + value | expected + ${'1ms'} | ${true} + ${'1M'} | ${true} + ${'1w'} | ${true} + ${'1d'} | ${true} + ${'1h'} | ${true} + ${'1m'} | ${true} + ${'1s'} | ${true} + ${'1y'} | ${true} + `( + "when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validationEvents[EventsWithValidation.onBlur][0].rule(value)).toBe(expected); + } + ); + + it.each` + value | expected + ${'1 ms'} | ${false} + ${'1x'} | ${false} + ${' '} | ${false} + ${'w'} | ${false} + ${'1.0s'} | ${false} + `( + "when calling the rule with incorrect formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validationEvents[EventsWithValidation.onBlur][0].rule(value)).toBe(expected); + } + ); + }); + describe('PromSettings component', () => { + const defaultProps = createDefaultConfigOptions(); + + it('should show POST httpMethod if no httpMethod and no url', () => { + const options = defaultProps; + options.url = ''; + options.jsonData.httpMethod = ''; + + render( +
    + {}} options={options} /> +
    + ); + expect(screen.getByText('POST')).toBeInTheDocument(); + }); + it('should show GET httpMethod if no httpMethod and url', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = ''; + + render( +
    + {}} options={options} /> +
    + ); + expect(screen.getByText('GET')).toBeInTheDocument(); + }); + it('should show POST httpMethod if POST httpMethod is configured', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = 'POST'; + + render( +
    + {}} options={options} /> +
    + ); + expect(screen.getByText('POST')).toBeInTheDocument(); + }); + it('should show GET httpMethod if GET httpMethod is configured', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = 'GET'; + + render( +
    + {}} options={options} /> +
    + ); + expect(screen.getByText('GET')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx new file mode 100644 index 0000000..6a0386d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx @@ -0,0 +1,167 @@ +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceJsonDataOptionChecked, + SelectableValue, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { EventsWithValidation, InlineFormLabel, LegacyForms, regexValidation } from '@grafana/ui'; +import React, { SyntheticEvent } from 'react'; +import { PromOptions } from '../types'; +import { ExemplarsSettings } from './ExemplarsSettings'; +const { Select, Input, FormField, Switch } = LegacyForms; + +const httpOptions = [ + { value: 'POST', label: 'POST' }, + { value: 'GET', label: 'GET' }, +]; + +type Props = Pick, 'options' | 'onOptionsChange'>; + +export const PromSettings = (props: Props) => { + const { options, onOptionsChange } = props; + + /** + * We want to change the default httpMethod to 'POST' for all of the new Prometheus data sources instances (no url) added in 7.5+. + * We are explicitly adding httpMethod, as previously it could be undefined and defaulted to 'GET'. + * Undefined httpMethod is still going to be considered 'GET' for backward compatibility reasons, but if users open data + * source settings it is going to be set to 'GET' explicitly and it will be selected in httpMethod dropdown as 'GET'. + * */ + + if (!options.jsonData.httpMethod) { + options.url ? (options.jsonData.httpMethod = 'GET') : (options.jsonData.httpMethod = 'POST'); + } + + return ( + <> +
    +
    +
    + + } + tooltip="Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s." + /> +
    +
    +
    +
    + + } + tooltip="Set the Prometheus query timeout." + /> +
    +
    +
    + + HTTP Method + + +
    +
    + step + +
    +
    + +
    +
    Field formatsFor title and text fields, use either the name or a pattern. For example, {{instance}} is replaced with label value for the label instance.
    +
    +
    + Title + +
    +
    + Tags + +
    +
    +
    + Text + +
    +
    +
    + +
    Other options
    +
    +
    + + +
    +
    +
    diff --git a/public/app/plugins/datasource/prometheus/plugin.json b/public/app/plugins/datasource/prometheus/plugin.json new file mode 100644 index 0000000..3de7942 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/plugin.json @@ -0,0 +1,88 @@ +{ + "type": "datasource", + "name": "Prometheus", + "id": "prometheus", + "category": "tsdb", + "routes": [ + { + "method": "POST", + "path": "api/v1/query", + "reqRole": "Viewer" + }, + { + "method": "POST", + "path": "api/v1/query_range", + "reqRole": "Viewer" + }, + { + "method": "POST", + "path": "api/v1/series", + "reqRole": "Viewer" + }, + { + "method": "POST", + "path": "api/v1/labels", + "reqRole": "Viewer" + }, + { + "method": "POST", + "path": "api/v1/query_exemplars", + "reqRole": "Viewer" + }, + { + "method": "GET", + "path": "/rules", + "reqRole": "Viewer" + }, + { + "method": "POST", + "path": "/rules", + "reqRole": "Editor" + }, + { + "method": "DELETE", + "path": "/rules", + "reqRole": "Editor" + } + ], + "includes": [ + { + "type": "dashboard", + "name": "Prometheus Stats", + "path": "dashboards/prometheus_stats.json" + }, + { + "type": "dashboard", + "name": "Prometheus 2.0 Stats", + "path": "dashboards/prometheus_2_stats.json" + }, + { + "type": "dashboard", + "name": "Grafana Stats", + "path": "dashboards/grafana_stats.json" + } + ], + "metrics": true, + "alerting": true, + "annotations": true, + "queryOptions": { + "minInterval": true + }, + "info": { + "description": "Open source time series database & alerting", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/prometheus_logo.svg", + "large": "img/prometheus_logo.svg" + }, + "links": [ + { + "name": "Learn more", + "url": "https://prometheus.io/" + } + ] + } +} diff --git a/public/app/plugins/datasource/prometheus/promql.test.ts b/public/app/plugins/datasource/prometheus/promql.test.ts new file mode 100644 index 0000000..2b0cc20 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/promql.test.ts @@ -0,0 +1,22 @@ +import promql from './promql'; +import Prism from 'prismjs'; + +describe('Loki syntax', () => { + it('should highlight Loki query correctly', () => { + expect(Prism.highlight('{key="val#ue"}', promql, 'promql')).toBe( + '{key="val#ue"}' + ); + expect(Prism.highlight('{key="#value"}', promql, 'promql')).toBe( + '{key="#value"}' + ); + expect(Prism.highlight('{key="value#"}', promql, 'promql')).toBe( + '{key="value#"}' + ); + expect(Prism.highlight('#test{key="value"}', promql, 'promql')).toBe( + '#test{key="value"}' + ); + expect(Prism.highlight('{key="value"}#test', promql, 'promql')).toBe( + '{key="value"}#test' + ); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts new file mode 100644 index 0000000..1400466 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/promql.ts @@ -0,0 +1,444 @@ +import { Grammar } from 'prismjs'; +import { CompletionItem } from '@grafana/ui'; + +// When changing RATE_RANGES, check if Loki/LogQL ranges should be changed too +// @see public/app/plugins/datasource/loki/language_provider.ts +export const RATE_RANGES: CompletionItem[] = [ + { label: '$__interval', sortValue: '$__interval' }, + { label: '$__rate_interval', sortValue: '$__rate_interval' }, + { label: '1m', sortValue: '00:01:00' }, + { label: '5m', sortValue: '00:05:00' }, + { label: '10m', sortValue: '00:10:00' }, + { label: '30m', sortValue: '00:30:00' }, + { label: '1h', sortValue: '01:00:00' }, + { label: '1d', sortValue: '24:00:00' }, +]; + +export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without']; + +const AGGREGATION_OPERATORS: CompletionItem[] = [ + { + label: 'sum', + insertText: 'sum', + documentation: 'Calculate sum over dimensions', + }, + { + label: 'min', + insertText: 'min', + documentation: 'Select minimum over dimensions', + }, + { + label: 'max', + insertText: 'max', + documentation: 'Select maximum over dimensions', + }, + { + label: 'avg', + insertText: 'avg', + documentation: 'Calculate the average over dimensions', + }, + { + label: 'stddev', + insertText: 'stddev', + documentation: 'Calculate population standard deviation over dimensions', + }, + { + label: 'stdvar', + insertText: 'stdvar', + documentation: 'Calculate population standard variance over dimensions', + }, + { + label: 'count', + insertText: 'count', + documentation: 'Count number of elements in the vector', + }, + { + label: 'count_values', + insertText: 'count_values', + documentation: 'Count number of elements with the same value', + }, + { + label: 'bottomk', + insertText: 'bottomk', + documentation: 'Smallest k elements by sample value', + }, + { + label: 'topk', + insertText: 'topk', + documentation: 'Largest k elements by sample value', + }, + { + label: 'quantile', + insertText: 'quantile', + documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions', + }, +]; + +export const FUNCTIONS = [ + ...AGGREGATION_OPERATORS, + { + insertText: 'abs', + label: 'abs', + detail: 'abs(v instant-vector)', + documentation: 'Returns the input vector with all sample values converted to their absolute value.', + }, + { + insertText: 'absent', + label: 'absent', + detail: 'absent(v instant-vector)', + documentation: + 'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.', + }, + { + insertText: 'ceil', + label: 'ceil', + detail: 'ceil(v instant-vector)', + documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.', + }, + { + insertText: 'changes', + label: 'changes', + detail: 'changes(v range-vector)', + documentation: + 'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.', + }, + { + insertText: 'clamp_max', + label: 'clamp_max', + detail: 'clamp_max(v instant-vector, max scalar)', + documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.', + }, + { + insertText: 'clamp_min', + label: 'clamp_min', + detail: 'clamp_min(v instant-vector, min scalar)', + documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.', + }, + { + insertText: 'count_scalar', + label: 'count_scalar', + detail: 'count_scalar(v instant-vector)', + documentation: + 'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.', + }, + { + insertText: 'day_of_month', + label: 'day_of_month', + detail: 'day_of_month(v=vector(time()) instant-vector)', + documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.', + }, + { + insertText: 'day_of_week', + label: 'day_of_week', + detail: 'day_of_week(v=vector(time()) instant-vector)', + documentation: + 'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.', + }, + { + insertText: 'days_in_month', + label: 'days_in_month', + detail: 'days_in_month(v=vector(time()) instant-vector)', + documentation: + 'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.', + }, + { + insertText: 'delta', + label: 'delta', + detail: 'delta(v range-vector)', + documentation: + 'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.', + }, + { + insertText: 'deriv', + label: 'deriv', + detail: 'deriv(v range-vector)', + documentation: + 'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.', + }, + { + insertText: 'drop_common_labels', + label: 'drop_common_labels', + detail: 'drop_common_labels(instant-vector)', + documentation: 'Drops all labels that have the same name and value across all series in the input vector.', + }, + { + insertText: 'exp', + label: 'exp', + detail: 'exp(v instant-vector)', + documentation: + 'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`', + }, + { + insertText: 'floor', + label: 'floor', + detail: 'floor(v instant-vector)', + documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.', + }, + { + insertText: 'histogram_quantile', + label: 'histogram_quantile', + detail: 'histogram_quantile(φ float, b instant-vector)', + documentation: + 'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.', + }, + { + insertText: 'holt_winters', + label: 'holt_winters', + detail: 'holt_winters(v range-vector, sf scalar, tf scalar)', + documentation: + 'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.', + }, + { + insertText: 'hour', + label: 'hour', + detail: 'hour(v=vector(time()) instant-vector)', + documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.', + }, + { + insertText: 'idelta', + label: 'idelta', + detail: 'idelta(v range-vector)', + documentation: + 'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.', + }, + { + insertText: 'increase', + label: 'increase', + detail: 'increase(v range-vector)', + documentation: + 'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.', + }, + { + insertText: 'irate', + label: 'irate', + detail: 'irate(v range-vector)', + documentation: + 'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.', + }, + { + insertText: 'label_replace', + label: 'label_replace', + detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)', + documentation: + "For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.", + }, + { + insertText: 'ln', + label: 'ln', + detail: 'ln(v instant-vector)', + documentation: + 'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`', + }, + { + insertText: 'log2', + label: 'log2', + detail: 'log2(v instant-vector)', + documentation: + 'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.', + }, + { + insertText: 'log10', + label: 'log10', + detail: 'log10(v instant-vector)', + documentation: + 'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.', + }, + { + insertText: 'minute', + label: 'minute', + detail: 'minute(v=vector(time()) instant-vector)', + documentation: + 'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.', + }, + { + insertText: 'month', + label: 'month', + detail: 'month(v=vector(time()) instant-vector)', + documentation: + 'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.', + }, + { + insertText: 'predict_linear', + label: 'predict_linear', + detail: 'predict_linear(v range-vector, t scalar)', + documentation: + 'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.', + }, + { + insertText: 'rate', + label: 'rate', + detail: 'rate(v range-vector)', + documentation: + "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.", + }, + { + insertText: 'resets', + label: 'resets', + detail: 'resets(v range-vector)', + documentation: + 'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.', + }, + { + insertText: 'round', + label: 'round', + detail: 'round(v instant-vector, to_nearest=1 scalar)', + documentation: + 'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.', + }, + { + insertText: 'scalar', + label: 'scalar', + detail: 'scalar(v instant-vector)', + documentation: + 'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.', + }, + { + insertText: 'sort', + label: 'sort', + detail: 'sort(v instant-vector)', + documentation: 'Returns vector elements sorted by their sample values, in ascending order.', + }, + { + insertText: 'sort_desc', + label: 'sort_desc', + detail: 'sort_desc(v instant-vector)', + documentation: 'Returns vector elements sorted by their sample values, in descending order.', + }, + { + insertText: 'sqrt', + label: 'sqrt', + detail: 'sqrt(v instant-vector)', + documentation: 'Calculates the square root of all elements in `v`.', + }, + { + insertText: 'time', + label: 'time', + detail: 'time()', + documentation: + 'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.', + }, + { + insertText: 'vector', + label: 'vector', + detail: 'vector(s scalar)', + documentation: 'Returns the scalar `s` as a vector with no labels.', + }, + { + insertText: 'year', + label: 'year', + detail: 'year(v=vector(time()) instant-vector)', + documentation: 'Returns the year for each of the given times in UTC.', + }, + { + insertText: 'avg_over_time', + label: 'avg_over_time', + detail: 'avg_over_time(range-vector)', + documentation: 'The average value of all points in the specified interval.', + }, + { + insertText: 'min_over_time', + label: 'min_over_time', + detail: 'min_over_time(range-vector)', + documentation: 'The minimum value of all points in the specified interval.', + }, + { + insertText: 'max_over_time', + label: 'max_over_time', + detail: 'max_over_time(range-vector)', + documentation: 'The maximum value of all points in the specified interval.', + }, + { + insertText: 'sum_over_time', + label: 'sum_over_time', + detail: 'sum_over_time(range-vector)', + documentation: 'The sum of all values in the specified interval.', + }, + { + insertText: 'count_over_time', + label: 'count_over_time', + detail: 'count_over_time(range-vector)', + documentation: 'The count of all values in the specified interval.', + }, + { + insertText: 'quantile_over_time', + label: 'quantile_over_time', + detail: 'quantile_over_time(scalar, range-vector)', + documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.', + }, + { + insertText: 'stddev_over_time', + label: 'stddev_over_time', + detail: 'stddev_over_time(range-vector)', + documentation: 'The population standard deviation of the values in the specified interval.', + }, + { + insertText: 'stdvar_over_time', + label: 'stdvar_over_time', + detail: 'stdvar_over_time(range-vector)', + documentation: 'The population standard variance of the values in the specified interval.', + }, +]; + +const tokenizer: Grammar = { + comment: { + pattern: /#.*/, + }, + 'context-aggregation': { + pattern: /((by|without)\s*)\([^)]*\)/, // by () + lookbehind: true, + inside: { + 'label-key': { + pattern: /[^(),\s][^,)]*[^),\s]*/, + alias: 'attr-name', + }, + punctuation: /[()]/, + }, + }, + 'context-labels': { + pattern: /\{[^}]*(?=}?)/, + greedy: true, + inside: { + comment: { + pattern: /#.*/, + }, + 'label-key': { + pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/, + alias: 'attr-name', + greedy: true, + }, + 'label-value': { + pattern: /"(?:\\.|[^\\"])*"/, + greedy: true, + alias: 'attr-value', + }, + punctuation: /[{]/, + }, + }, + function: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})(?=\\s*\\()`, 'i'), + 'context-range': [ + { + pattern: /\[[^\]]*(?=])/, // [1m] + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + { + pattern: /(offset\s+)\w+/, // offset 1m + lookbehind: true, + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + ], + number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, + operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'), + punctuation: /[{};()`,.]/, +}; + +export default tokenizer; diff --git a/public/app/plugins/datasource/prometheus/query_hints.test.ts b/public/app/plugins/datasource/prometheus/query_hints.test.ts new file mode 100644 index 0000000..1dbaf79 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/query_hints.test.ts @@ -0,0 +1,144 @@ +import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from './query_hints'; +import { PrometheusDatasource } from './datasource'; + +describe('getQueryHints()', () => { + it('returns no hints for no series', () => { + expect(getQueryHints('', [])).toEqual([]); + }); + + it('returns no hints for empty series', () => { + expect(getQueryHints('', [{ datapoints: [] }])).toEqual([]); + }); + + it('returns a rate hint for a counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_total', series); + + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Metric metric_total looks like a counter.', + fix: { + action: { + type: 'ADD_RATE', + query: 'metric_total', + }, + }, + }); + }); + + it('returns a certain rate hint for a counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const mock: unknown = { languageProvider: { metricsMetadata: { foo: [{ type: 'counter' }] } } }; + const datasource = mock as PrometheusDatasource; + + let hints = getQueryHints('foo', series, datasource); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Metric foo is a counter.', + fix: { + action: { + type: 'ADD_RATE', + query: 'foo', + }, + }, + }); + + // Test substring match not triggering hint + hints = getQueryHints('foo_foo', series, datasource); + expect(hints).toEqual([]); + }); + + it('returns no rate hint for a counter metric that already has a rate', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('rate(metric_total[1m])', series); + expect(hints).toEqual([]); + }); + + it('returns no rate hint for a counter metric that already has an increase', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('increase(metric_total[1m])', series); + expect(hints).toEqual([]); + }); + + it('returns a rate hint w/o action for a complex counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('sum(metric_total)', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('rate()'); + expect(hints![0].fix).toBeUndefined(); + }); + + it('returns a histogram hint for a bucket series', () => { + const series = [{ datapoints: [[23, 1000]] }]; + const hints = getQueryHints('metric_bucket', series); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Time series has buckets, you probably wanted a histogram.', + fix: { + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query: 'metric_bucket', + }, + }, + }); + }); + + it('returns a sum hint when many time series results are returned for a simple metric', () => { + const seriesCount = SUM_HINT_THRESHOLD_COUNT; + const series = Array.from({ length: seriesCount }, (_) => ({ + datapoints: [ + [0, 0], + [0, 0], + ], + })); + const hints = getQueryHints('metric', series); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: 'metric', + preventSubmit: true, + }, + }, + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts new file mode 100644 index 0000000..9f8d2d0 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -0,0 +1,129 @@ +import { size } from 'lodash'; +import { QueryHint, QueryFix } from '@grafana/data'; +import { PrometheusDatasource } from './datasource'; + +/** + * 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?: PrometheusDatasource): 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, + }, + } as QueryFix, + }); + } + + // Check for need of rate() + if (query.indexOf('rate(') === -1 && query.indexOf('increase(') === -1) { + // Use metric metadata for exact types + const nameMatch = query.match(/\b(\w+_(total|sum|count))\b/); + let counterNameMetric = nameMatch ? nameMatch[1] : ''; + const metricsMetadata = datasource?.languageProvider?.metricsMetadata ?? {}; + const metricMetadataKeys = Object.keys(metricsMetadata); + let certain = false; + + if (metricMetadataKeys.length > 0) { + counterNameMetric = + metricMetadataKeys.find((metricName) => { + // Only considering first type information, could be non-deterministic + const metadata = metricsMetadata[metricName][0]; + if (metadata.type.toLowerCase() === 'counter') { + const metricRegex = new RegExp(`\\b${metricName}\\b`); + if (query.match(metricRegex)) { + certain = true; + return true; + } + } + return false; + }) ?? ''; + } + + if (counterNameMetric) { + const simpleMetric = query.trim().match(/^\w+$/); + const verb = certain ? 'is' : 'looks like'; + let label = `Metric ${counterNameMetric} ${verb} a counter.`; + let fix: QueryFix | undefined; + + if (simpleMetric) { + fix = { + label: 'Fix by adding rate().', + action: { + type: 'ADD_RATE', + query, + }, + }; + } else { + label = `${label} Try applying a rate() function.`; + } + + hints.push({ + type: 'APPLY_RATE', + label, + fix, + }); + } + } + + // Check for recording rules expansion + if (datasource && datasource.ruleMappings) { + const mapping = datasource.ruleMappings; + const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { + if (query.search(ruleName) > -1) { + return { + ...acc, + [ruleName]: mapping[ruleName], + }; + } + return acc; + }, {}); + if (size(mappingForQuery) > 0) { + const label = 'Query contains recording rules.'; + hints.push({ + type: 'EXPAND_RULES', + label, + fix: ({ + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + mapping: mappingForQuery, + }, + } as any) as QueryFix, + }); + } + } + + if (series && 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, + }, + } as QueryFix, + }); + } + } + + return hints; +} diff --git a/public/app/plugins/datasource/prometheus/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/result_transformer.test.ts new file mode 100644 index 0000000..1e47b24 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/result_transformer.test.ts @@ -0,0 +1,587 @@ +import { DataFrame, FieldType } from '@grafana/data'; +import { transform } from './result_transformer'; + +jest.mock('@grafana/runtime', () => ({ + getTemplateSrv: () => ({ + replace: (str: string) => str, + }), + getDataSourceSrv: () => { + return { + getInstanceSettings: () => { + return { name: 'Tempo' }; + }, + }; + }, +})); + +const matrixResponse = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [1, '10'], + [2, '0'], + ], + }, + ], + }, +}; + +describe('Prometheus Result Transformer', () => { + const options: any = { target: {}, query: {} }; + describe('When nothing is returned', () => { + it('should return empty array', () => { + const response = { + status: 'success', + data: { + resultType: '', + result: null, + }, + }; + const series = transform({ data: response } as any, options); + expect(series).toEqual([]); + }); + it('should return empty array', () => { + const response = { + status: 'success', + data: { + resultType: '', + result: null, + }, + }; + const result = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result).toHaveLength(0); + }); + }); + + describe('When resultFormat is table', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [1443454528, '3846'], + [1443454530, '3848'], + ], + }, + { + metric: { + __name__: 'test2', + instance: 'localhost:8080', + job: 'otherjob', + }, + values: [ + [1443454529, '3847'], + [1443454531, '3849'], + ], + }, + ], + }, + }; + + it('should return data frame', () => { + const result = transform({ data: response } as any, { + ...options, + target: { + responseListLength: 0, + refId: 'A', + format: 'table', + }, + }); + expect(result[0].fields[0].values.toArray()).toEqual([ + 1443454528000, + 1443454530000, + 1443454529000, + 1443454531000, + ]); + expect(result[0].fields[0].name).toBe('Time'); + expect(result[0].fields[0].type).toBe(FieldType.time); + expect(result[0].fields[1].values.toArray()).toEqual(['test', 'test', 'test2', 'test2']); + expect(result[0].fields[1].name).toBe('__name__'); + expect(result[0].fields[1].config.filterable).toBe(true); + expect(result[0].fields[1].type).toBe(FieldType.string); + expect(result[0].fields[2].values.toArray()).toEqual(['', '', 'localhost:8080', 'localhost:8080']); + expect(result[0].fields[2].name).toBe('instance'); + expect(result[0].fields[2].type).toBe(FieldType.string); + expect(result[0].fields[3].values.toArray()).toEqual(['testjob', 'testjob', 'otherjob', 'otherjob']); + expect(result[0].fields[3].name).toBe('job'); + expect(result[0].fields[3].type).toBe(FieldType.string); + expect(result[0].fields[4].values.toArray()).toEqual([3846, 3848, 3847, 3849]); + expect(result[0].fields[4].name).toEqual('Value'); + expect(result[0].fields[4].type).toBe(FieldType.number); + expect(result[0].refId).toBe('A'); + }); + + it('should include refId if response count is more than 2', () => { + const result = transform({ data: response } as any, { + ...options, + target: { + refId: 'B', + format: 'table', + }, + responseListLength: 2, + }); + + expect(result[0].fields[4].name).toEqual('Value #B'); + }); + }); + + describe('When resultFormat is table and instant = true', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '3846'], + }, + ], + }, + }; + + it('should return data frame', () => { + const result = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[0].values.toArray()).toEqual([1443454528000]); + expect(result[0].fields[0].name).toBe('Time'); + expect(result[0].fields[1].values.toArray()).toEqual(['test']); + expect(result[0].fields[1].name).toBe('__name__'); + expect(result[0].fields[2].values.toArray()).toEqual(['testjob']); + expect(result[0].fields[2].name).toBe('job'); + expect(result[0].fields[3].values.toArray()).toEqual([3846]); + expect(result[0].fields[3].name).toEqual('Value'); + }); + + it('should return le label values parsed as numbers', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { le: '102' }, + value: [1594908838, '0'], + }, + ], + }, + }; + const result = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[1].values.toArray()).toEqual([102]); + expect(result[0].fields[1].type).toEqual(FieldType.number); + }); + }); + + describe('When instant = true', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '3846'], + }, + ], + }, + }; + + it('should return data frame', () => { + const result: DataFrame[] = transform({ data: response } as any, { ...options, query: { instant: true } }); + expect(result[0].name).toBe('test{job="testjob"}'); + }); + }); + + describe('When resultFormat is heatmap', () => { + const getResponse = (result: any) => ({ + status: 'success', + data: { + resultType: 'matrix', + result, + }, + }); + + const options = { + format: 'heatmap', + start: 1445000010, + end: 1445000030, + legendFormat: '{{le}}', + }; + + it('should convert cumulative histogram to regular', () => { + const response = getResponse([ + { + metric: { __name__: 'test', job: 'testjob', le: '1' }, + values: [ + [1445000010, '10'], + [1445000020, '10'], + [1445000030, '0'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '2' }, + values: [ + [1445000010, '20'], + [1445000020, '10'], + [1445000030, '30'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '3' }, + values: [ + [1445000010, '30'], + [1445000020, '10'], + [1445000030, '40'], + ], + }, + ]); + + const result = transform({ data: response } as any, { query: options, target: options } as any); + expect(result[0].fields[0].values.toArray()).toEqual([1445000010000, 1445000020000, 1445000030000]); + expect(result[0].fields[1].values.toArray()).toEqual([10, 10, 0]); + expect(result[1].fields[0].values.toArray()).toEqual([1445000010000, 1445000020000, 1445000030000]); + expect(result[1].fields[1].values.toArray()).toEqual([10, 0, 30]); + expect(result[2].fields[0].values.toArray()).toEqual([1445000010000, 1445000020000, 1445000030000]); + expect(result[2].fields[1].values.toArray()).toEqual([10, 0, 10]); + }); + + it('should handle missing datapoints', () => { + const response = getResponse([ + { + metric: { __name__: 'test', job: 'testjob', le: '1' }, + values: [ + [1445000010, '1'], + [1445000020, '2'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '2' }, + values: [ + [1445000010, '2'], + [1445000020, '5'], + [1445000030, '1'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '3' }, + values: [ + [1445000010, '3'], + [1445000020, '7'], + ], + }, + ]); + const result = transform({ data: response } as any, { query: options, target: options } as any); + expect(result[0].fields[1].values.toArray()).toEqual([1, 2]); + expect(result[1].fields[1].values.toArray()).toEqual([1, 3, 1]); + expect(result[2].fields[1].values.toArray()).toEqual([1, 2]); + }); + }); + + describe('When the response is a matrix', () => { + it('should have labels with the value field', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob', instance: 'testinstance' }, + values: [ + [0, '10'], + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result: DataFrame[] = transform({ data: response } as any, { + ...options, + }); + + expect(result[0].fields[1].labels).toBeDefined(); + expect(result[0].fields[1].labels?.instance).toBe('testinstance'); + expect(result[0].fields[1].labels?.job).toBe('testjob'); + }); + + it('should transform into a data frame', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [0, '10'], + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result: DataFrame[] = transform({ data: response } as any, { + ...options, + query: { + start: 0, + end: 2, + }, + }); + expect(result[0].fields[0].values.toArray()).toEqual([0, 1000, 2000]); + expect(result[0].fields[1].values.toArray()).toEqual([10, 10, 0]); + expect(result[0].name).toBe('test{job="testjob"}'); + }); + + it('should fill null values', () => { + const result = transform({ data: matrixResponse } as any, { ...options, query: { step: 1, start: 0, end: 2 } }); + + expect(result[0].fields[0].values.toArray()).toEqual([0, 1000, 2000]); + expect(result[0].fields[1].values.toArray()).toEqual([null, 10, 0]); + }); + + it('should use __name__ label as series name', () => { + const result = transform({ data: matrixResponse } as any, { + ...options, + query: { + step: 1, + start: 0, + end: 2, + }, + }); + expect(result[0].name).toEqual('test{job="testjob"}'); + }); + + it('should use query as series name when __name__ is not available and metric is empty', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: {}, + values: [[0, '10']], + }, + ], + }, + }; + const expr = 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))'; + const result = transform({ data: response } as any, { + ...options, + query: { + step: 1, + start: 0, + end: 2, + expr, + }, + }); + expect(result[0].name).toEqual(expr); + }); + + it('should set frame name to undefined if no __name__ label but there are other labels', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { job: 'testjob' }, + values: [ + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result = transform({ data: response } as any, { + ...options, + query: { + step: 1, + start: 0, + end: 2, + }, + }); + expect(result[0].name).toBe('{job="testjob"}'); + }); + + it('should not set displayName for ValueFields', () => { + const result = transform({ data: matrixResponse } as any, options); + expect(result[0].fields[1].config.displayName).toBeUndefined(); + expect(result[0].fields[1].config.displayNameFromDS).toBe('test{job="testjob"}'); + }); + + it('should align null values with step', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [4, '10'], + [8, '10'], + ], + }, + ], + }, + }; + + const result = transform({ data: response } as any, { ...options, query: { step: 2, start: 0, end: 8 } }); + expect(result[0].fields[0].values.toArray()).toEqual([0, 2000, 4000, 6000, 8000]); + expect(result[0].fields[1].values.toArray()).toEqual([null, null, 10, null, 10]); + }); + }); + + describe('When infinity values are returned', () => { + describe('When resultType is scalar', () => { + const response = { + status: 'success', + data: { + resultType: 'scalar', + result: [1443454528, '+Inf'], + }, + }; + + it('should correctly parse values', () => { + const result: DataFrame[] = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[1].values.toArray()).toEqual([Number.POSITIVE_INFINITY]); + }); + }); + + describe('When resultType is vector', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '+Inf'], + }, + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '-Inf'], + }, + ], + }, + }; + + describe('When format is table', () => { + it('should correctly parse values', () => { + const result: DataFrame[] = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[3].values.toArray()).toEqual([Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]); + }); + }); + }); + }); + + const exemplarsResponse = { + status: 'success', + data: [ + { + seriesLabels: { __name__: 'test' }, + exemplars: [ + { + timestamp: 1610449069.957, + labels: { traceID: '5020b5bc45117f07' }, + value: 0.002074123, + }, + ], + }, + ], + }; + + describe('When the response is exemplar data', () => { + it('should return as an data frame with a dataTopic annotations', () => { + const result = transform({ data: exemplarsResponse } as any, options); + + expect(result[0].meta?.dataTopic).toBe('annotations'); + expect(result[0].fields.length).toBe(4); // __name__, traceID, Time, Value + expect(result[0].length).toBe(1); + }); + + it('should return with an empty array when data is empty', () => { + const result = transform( + { + data: { + status: 'success', + data: [], + }, + } as any, + options + ); + + expect(result).toHaveLength(0); + }); + + it('should remove exemplars that are too close to each other', () => { + const response = { + status: 'success', + data: [ + { + exemplars: [ + { + timestamp: 1610449070.0, + value: 5, + }, + { + timestamp: 1610449070.0, + value: 1, + }, + { + timestamp: 1610449070.5, + value: 13, + }, + { + timestamp: 1610449070.3, + value: 20, + }, + ], + }, + ], + }; + /** + * the standard deviation for the above values is 8.4 this means that we show the highest + * value (20) and then the next value should be 2 times the standard deviation which is 1 + **/ + const result = transform({ data: response } as any, options); + expect(result[0].length).toBe(2); + }); + + describe('data link', () => { + it('should be added to the field if found with url', () => { + const result = transform({ data: exemplarsResponse } as any, { + ...options, + exemplarTraceIdDestinations: [{ name: 'traceID', url: 'http://localhost' }], + }); + + expect(result[0].fields.some((f) => f.config.links?.length)).toBe(true); + }); + + it('should be added to the field if found with internal link', () => { + const result = transform({ data: exemplarsResponse } as any, { + ...options, + exemplarTraceIdDestinations: [{ name: 'traceID', datasourceUid: 'jaeger' }], + }); + + expect(result[0].fields.some((f) => f.config.links?.length)).toBe(true); + }); + + it('should not add link if exemplarTraceIdDestinations is not configured', () => { + const result = transform({ data: exemplarsResponse } as any, options); + + expect(result[0].fields.some((f) => f.config.links?.length)).toBe(false); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts new file mode 100644 index 0000000..ae55bcf --- /dev/null +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -0,0 +1,458 @@ +import { + ArrayDataFrame, + ArrayVector, + DataFrame, + DataLink, + DataTopic, + Field, + FieldType, + formatLabels, + getDisplayProcessor, + Labels, + MutableField, + ScopedVars, + TIME_SERIES_TIME_FIELD_NAME, + TIME_SERIES_VALUE_FIELD_NAME, +} from '@grafana/data'; +import { FetchResponse, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; +import { descending, deviation } from 'd3'; +import { + ExemplarTraceIdDestination, + isExemplarData, + isMatrixData, + MatrixOrVectorResult, + PromDataSuccessResponse, + PromMetric, + PromQuery, + PromQueryRequest, + PromValue, + TransformOptions, +} from './types'; + +const POSITIVE_INFINITY_SAMPLE_VALUE = '+Inf'; +const NEGATIVE_INFINITY_SAMPLE_VALUE = '-Inf'; + +interface TimeAndValue { + [TIME_SERIES_TIME_FIELD_NAME]: number; + [TIME_SERIES_VALUE_FIELD_NAME]: number; +} + +export function transform( + response: FetchResponse, + transformOptions: { + query: PromQueryRequest; + exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; + target: PromQuery; + responseListLength: number; + scopedVars?: ScopedVars; + } +) { + // Create options object from transformOptions + const options: TransformOptions = { + format: transformOptions.target.format, + step: transformOptions.query.step, + legendFormat: transformOptions.target.legendFormat, + start: transformOptions.query.start, + end: transformOptions.query.end, + query: transformOptions.query.expr, + responseListLength: transformOptions.responseListLength, + scopedVars: transformOptions.scopedVars, + refId: transformOptions.target.refId, + valueWithRefId: transformOptions.target.valueWithRefId, + meta: { + // Fix for showing of Prometheus results in Explore table + preferredVisualisationType: transformOptions.query.instant ? 'table' : 'graph', + }, + }; + const prometheusResult = response.data.data; + + if (isExemplarData(prometheusResult)) { + const events: TimeAndValue[] = []; + prometheusResult.forEach((exemplarData) => { + const data = exemplarData.exemplars.map((exemplar) => { + return { + [TIME_SERIES_TIME_FIELD_NAME]: exemplar.timestamp * 1000, + [TIME_SERIES_VALUE_FIELD_NAME]: exemplar.value, + ...exemplar.labels, + ...exemplarData.seriesLabels, + }; + }); + events.push(...data); + }); + + // Grouping exemplars by step + const sampledExemplars = sampleExemplars(events, options); + + const dataFrame = new ArrayDataFrame(sampledExemplars); + dataFrame.meta = { dataTopic: DataTopic.Annotations }; + + // Add data links if configured + if (transformOptions.exemplarTraceIdDestinations?.length) { + for (const exemplarTraceIdDestination of transformOptions.exemplarTraceIdDestinations) { + const traceIDField = dataFrame.fields.find((field) => field.name === exemplarTraceIdDestination!.name); + if (traceIDField) { + const links = getDataLinks(exemplarTraceIdDestination); + traceIDField.config.links = traceIDField.config.links?.length + ? [...traceIDField.config.links, ...links] + : links; + } + } + } + return [dataFrame]; + } + + if (!prometheusResult?.result) { + return []; + } + + // Return early if result type is scalar + if (prometheusResult.resultType === 'scalar') { + return [ + { + meta: options.meta, + refId: options.refId, + length: 1, + fields: [getTimeField([prometheusResult.result]), getValueField({ data: [prometheusResult.result] })], + }, + ]; + } + + // Return early again if the format is table, this needs special transformation. + if (options.format === 'table') { + const tableData = transformMetricDataToTable(prometheusResult.result, options); + return [tableData]; + } + + // Process matrix and vector results to DataFrame + const dataFrame: DataFrame[] = []; + prometheusResult.result.forEach((data: MatrixOrVectorResult) => dataFrame.push(transformToDataFrame(data, options))); + + // When format is heatmap use the already created data frames and transform it more + if (options.format === 'heatmap') { + dataFrame.sort(sortSeriesByLabel); + const seriesList = transformToHistogramOverTime(dataFrame); + return seriesList; + } + + // Return matrix or vector result as DataFrame[] + return dataFrame; +} + +function getDataLinks(options: ExemplarTraceIdDestination): DataLink[] { + const dataLinks: DataLink[] = []; + + if (options.datasourceUid) { + const dataSourceSrv = getDataSourceSrv(); + const dsSettings = dataSourceSrv.getInstanceSettings(options.datasourceUid); + + dataLinks.push({ + title: `Query with ${dsSettings?.name}`, + url: '', + internal: { + query: { query: '${__value.raw}', queryType: 'traceId' }, + datasourceUid: options.datasourceUid, + datasourceName: dsSettings?.name ?? 'Data source not found', + }, + }); + } + + if (options.url) { + dataLinks.push({ + title: `Go to ${options.url}`, + url: options.url, + targetBlank: true, + }); + } + return dataLinks; +} + +/** + * Reduce the density of the exemplars by making sure that the highest value exemplar is included + * and then only the ones that are 2 times the standard deviation of the all the values. + * This makes sure not to show too many dots near each other. + */ +function sampleExemplars(events: TimeAndValue[], options: TransformOptions) { + const step = options.step || 15; + const bucketedExemplars: { [ts: string]: TimeAndValue[] } = {}; + const values: number[] = []; + for (const exemplar of events) { + // Align exemplar timestamp to nearest step second + const alignedTs = String(Math.floor(exemplar[TIME_SERIES_TIME_FIELD_NAME] / 1000 / step) * step * 1000); + if (!bucketedExemplars[alignedTs]) { + // New bucket found + bucketedExemplars[alignedTs] = []; + } + bucketedExemplars[alignedTs].push(exemplar); + values.push(exemplar[TIME_SERIES_VALUE_FIELD_NAME]); + } + + // Getting exemplars from each bucket + const standardDeviation = deviation(values); + const sampledBuckets = Object.keys(bucketedExemplars).sort(); + const sampledExemplars = []; + for (const ts of sampledBuckets) { + const exemplarsInBucket = bucketedExemplars[ts]; + if (exemplarsInBucket.length === 1) { + sampledExemplars.push(exemplarsInBucket[0]); + } else { + // Choose which values to sample + const bucketValues = exemplarsInBucket.map((ex) => ex[TIME_SERIES_VALUE_FIELD_NAME]).sort(descending); + const sampledBucketValues = bucketValues.reduce((acc: number[], curr) => { + if (acc.length === 0) { + // First value is max and is always added + acc.push(curr); + } else { + // Then take values only when at least 2 standard deviation distance to previously taken value + const prev = acc[acc.length - 1]; + if (standardDeviation && prev - curr >= 2 * standardDeviation) { + acc.push(curr); + } + } + return acc; + }, []); + // Find the exemplars for the sampled values + sampledExemplars.push( + ...sampledBucketValues.map( + (value) => exemplarsInBucket.find((ex) => ex[TIME_SERIES_VALUE_FIELD_NAME] === value)! + ) + ); + } + } + return sampledExemplars; +} + +/** + * Transforms matrix and vector result from Prometheus result to DataFrame + */ +function transformToDataFrame(data: MatrixOrVectorResult, options: TransformOptions): DataFrame { + const { name, labels } = createLabelInfo(data.metric, options); + + const fields: Field[] = []; + + if (isMatrixData(data)) { + const stepMs = options.step ? options.step * 1000 : NaN; + let baseTimestamp = options.start * 1000; + const dps: PromValue[] = []; + + for (const value of data.values) { + let dpValue: number | null = parseSampleValue(value[1]); + + if (isNaN(dpValue)) { + dpValue = null; + } + + const timestamp = value[0] * 1000; + for (let t = baseTimestamp; t < timestamp; t += stepMs) { + dps.push([t, null]); + } + baseTimestamp = timestamp + stepMs; + dps.push([timestamp, dpValue]); + } + + const endTimestamp = options.end * 1000; + for (let t = baseTimestamp; t <= endTimestamp; t += stepMs) { + dps.push([t, null]); + } + fields.push(getTimeField(dps, true)); + fields.push(getValueField({ data: dps, parseValue: false, labels, displayNameFromDS: name })); + } else { + fields.push(getTimeField([data.value])); + fields.push(getValueField({ data: [data.value], labels, displayNameFromDS: name })); + } + + return { + meta: options.meta, + refId: options.refId, + length: fields[0].values.length, + fields, + name, + }; +} + +function transformMetricDataToTable(md: MatrixOrVectorResult[], options: TransformOptions): DataFrame { + if (!md || md.length === 0) { + return { + meta: options.meta, + refId: options.refId, + length: 0, + fields: [], + }; + } + + const valueText = options.responseListLength > 1 || options.valueWithRefId ? `Value #${options.refId}` : 'Value'; + + const timeField = getTimeField([]); + const metricFields = Object.keys(md.reduce((acc, series) => ({ ...acc, ...series.metric }), {})) + .sort() + .map((label) => { + // Labels have string field type, otherwise table tries to figure out the type which can result in unexpected results + // Only "le" label has a number field type + const numberField = label === 'le'; + return { + name: label, + config: { filterable: true }, + type: numberField ? FieldType.number : FieldType.string, + values: new ArrayVector(), + }; + }); + const valueField = getValueField({ data: [], valueName: valueText }); + + md.forEach((d) => { + if (isMatrixData(d)) { + d.values.forEach((val) => { + timeField.values.add(val[0] * 1000); + metricFields.forEach((metricField) => metricField.values.add(getLabelValue(d.metric, metricField.name))); + valueField.values.add(parseSampleValue(val[1])); + }); + } else { + timeField.values.add(d.value[0] * 1000); + metricFields.forEach((metricField) => metricField.values.add(getLabelValue(d.metric, metricField.name))); + valueField.values.add(parseSampleValue(d.value[1])); + } + }); + + return { + meta: options.meta, + refId: options.refId, + length: timeField.values.length, + fields: [timeField, ...metricFields, valueField], + }; +} + +function getLabelValue(metric: PromMetric, label: string): string | number { + if (metric.hasOwnProperty(label)) { + if (label === 'le') { + return parseSampleValue(metric[label]); + } + return metric[label]; + } + return ''; +} + +function getTimeField(data: PromValue[], isMs = false): MutableField { + return { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: {}, + values: new ArrayVector(data.map((val) => (isMs ? val[0] : val[0] * 1000))), + }; +} +type ValueFieldOptions = { + data: PromValue[]; + valueName?: string; + parseValue?: boolean; + labels?: Labels; + displayNameFromDS?: string; +}; + +function getValueField({ + data, + valueName = TIME_SERIES_VALUE_FIELD_NAME, + parseValue = true, + labels, + displayNameFromDS, +}: ValueFieldOptions): MutableField { + return { + name: valueName, + type: FieldType.number, + display: getDisplayProcessor(), + config: { + displayNameFromDS, + }, + labels, + values: new ArrayVector(data.map((val) => (parseValue ? parseSampleValue(val[1]) : val[1]))), + }; +} + +function createLabelInfo(labels: { [key: string]: string }, options: TransformOptions) { + if (options?.legendFormat) { + const title = renderTemplate(getTemplateSrv().replace(options.legendFormat, options?.scopedVars), labels); + return { name: title, labels }; + } + + const { __name__, ...labelsWithoutName } = labels; + const labelPart = formatLabels(labelsWithoutName); + let title = `${__name__ ?? ''}${labelPart}`; + + if (!title) { + title = options.query; + } + + return { name: title, labels: labelsWithoutName }; +} + +export function getOriginalMetricName(labelData: { [key: string]: string }) { + const metricName = labelData.__name__ || ''; + delete labelData.__name__; + const labelPart = Object.entries(labelData) + .map((label) => `${label[0]}="${label[1]}"`) + .join(','); + return `${metricName}{${labelPart}}`; +} + +export function renderTemplate(aliasPattern: string, aliasData: { [key: string]: string }) { + const aliasRegex = /\{\{\s*(.+?)\s*\}\}/g; + return aliasPattern.replace(aliasRegex, (_match, g1) => { + if (aliasData[g1]) { + return aliasData[g1]; + } + return ''; + }); +} + +function transformToHistogramOverTime(seriesList: DataFrame[]) { + /* t1 = timestamp1, t2 = timestamp2 etc. + t1 t2 t3 t1 t2 t3 + le10 10 10 0 => 10 10 0 + le20 20 10 30 => 10 0 30 + le30 30 10 35 => 10 0 5 + */ + for (let i = seriesList.length - 1; i > 0; i--) { + const topSeries = seriesList[i].fields.find((s) => s.name === TIME_SERIES_VALUE_FIELD_NAME); + const bottomSeries = seriesList[i - 1].fields.find((s) => s.name === TIME_SERIES_VALUE_FIELD_NAME); + if (!topSeries || !bottomSeries) { + throw new Error('Prometheus heatmap transform error: data should be a time series'); + } + + for (let j = 0; j < topSeries.values.length; j++) { + const bottomPoint = bottomSeries.values.get(j) || [0]; + topSeries.values.toArray()[j] -= bottomPoint; + } + } + + return seriesList; +} + +function sortSeriesByLabel(s1: DataFrame, s2: DataFrame): number { + let le1, le2; + + try { + // fail if not integer. might happen with bad queries + le1 = parseSampleValue(s1.name ?? ''); + le2 = parseSampleValue(s2.name ?? ''); + } catch (err) { + console.error(err); + return 0; + } + + if (le1 > le2) { + return 1; + } + + if (le1 < le2) { + return -1; + } + + return 0; +} + +function parseSampleValue(value: string): number { + switch (value) { + case POSITIVE_INFINITY_SAMPLE_VALUE: + return Number.POSITIVE_INFINITY; + case NEGATIVE_INFINITY_SAMPLE_VALUE: + return Number.NEGATIVE_INFINITY; + default: + return parseFloat(value); + } +} diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts new file mode 100644 index 0000000..cdf857d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -0,0 +1,148 @@ +import { DataQuery, DataSourceJsonData, QueryResultMeta, ScopedVars } from '@grafana/data'; +import { FetchError } from '@grafana/runtime'; + +export interface PromQuery extends DataQuery { + expr: string; + format?: string; + instant?: boolean; + range?: boolean; + exemplar?: boolean; + hinting?: boolean; + interval?: string; + intervalFactor?: number; + legendFormat?: string; + valueWithRefId?: boolean; + requestId?: string; + showingGraph?: boolean; + showingTable?: boolean; +} + +export interface PromOptions extends DataSourceJsonData { + timeInterval: string; + queryTimeout: string; + httpMethod: string; + directUrl: string; + customQueryParameters?: string; + disableMetricsLookup?: boolean; + exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; +} + +export type ExemplarTraceIdDestination = { + name: string; + url?: string; + datasourceUid?: string; +}; + +export interface PromQueryRequest extends PromQuery { + step?: number; + requestId?: string; + start: number; + end: number; + headers?: any; +} + +export interface PromMetricsMetadataItem { + type: string; + help: string; + unit?: string; +} + +export interface PromMetricsMetadata { + [metric: string]: PromMetricsMetadataItem[]; +} + +export interface PromDataSuccessResponse { + status: 'success'; + data: T; +} + +export interface PromDataErrorResponse { + status: 'error'; + errorType: string; + error: string; + data: T; +} + +export type PromData = PromMatrixData | PromVectorData | PromScalarData | PromExemplarData[]; + +export interface Labels { + [index: string]: any; +} + +export interface Exemplar { + labels: Labels; + value: number; + timestamp: number; +} + +export interface PromExemplarData { + seriesLabels: PromMetric; + exemplars: Exemplar[]; +} + +export interface PromVectorData { + resultType: 'vector'; + result: Array<{ + metric: PromMetric; + value: PromValue; + }>; +} + +export interface PromMatrixData { + resultType: 'matrix'; + result: Array<{ + metric: PromMetric; + values: PromValue[]; + }>; +} + +export interface PromScalarData { + resultType: 'scalar'; + result: PromValue; +} + +export type PromValue = [number, any]; + +export interface PromMetric { + __name__?: string; + [index: string]: any; +} + +export function isFetchErrorResponse(response: any): response is FetchError { + return 'cancelled' in response; +} + +export function isMatrixData(result: MatrixOrVectorResult): result is PromMatrixData['result'][0] { + return 'values' in result; +} + +export function isExemplarData(result: PromData): result is PromExemplarData[] { + if (result == null || !Array.isArray(result)) { + return false; + } + return result.length ? 'exemplars' in result[0] : false; +} + +export type MatrixOrVectorResult = PromMatrixData['result'][0] | PromVectorData['result'][0]; + +export interface TransformOptions { + format?: string; + step?: number; + legendFormat?: string; + start: number; + end: number; + query: string; + responseListLength: number; + scopedVars?: ScopedVars; + refId: string; + valueWithRefId?: boolean; + meta: QueryResultMeta; +} + +export interface PromLabelQueryResponse { + data: { + status: string; + data: string[]; + }; + cancelled?: boolean; +} diff --git a/public/app/plugins/datasource/prometheus/variables.ts b/public/app/plugins/datasource/prometheus/variables.ts new file mode 100644 index 0000000..27954f5 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/variables.ts @@ -0,0 +1,56 @@ +import { from, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + DataQueryRequest, + DataQueryResponse, + rangeUtil, + StandardVariableQuery, + StandardVariableSupport, +} from '@grafana/data'; +import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; + +import { PrometheusDatasource } from './datasource'; +import { PromQuery } from './types'; +import PrometheusMetricFindQuery from './metric_find_query'; +import { getTimeSrv, TimeSrv } from '../../../features/dashboard/services/TimeSrv'; + +export class PrometheusVariableSupport extends StandardVariableSupport { + constructor( + private readonly datasource: PrometheusDatasource, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(); + this.query = this.query.bind(this); + } + + query(request: DataQueryRequest): Observable { + const query = request.targets[0].expr; + if (!query) { + return of({ data: [] }); + } + + const scopedVars = { + ...request.scopedVars, + __interval: { text: this.datasource.interval, value: this.datasource.interval }, + __interval_ms: { + text: rangeUtil.intervalToMs(this.datasource.interval), + value: rangeUtil.intervalToMs(this.datasource.interval), + }, + ...this.datasource.getRangeScopedVars(this.timeSrv.timeRange()), + }; + + const interpolated = this.templateSrv.replace(query, scopedVars, this.datasource.interpolateQueryExpr); + const metricFindQuery = new PrometheusMetricFindQuery(this.datasource, interpolated); + const metricFindStream = from(metricFindQuery.process()); + + return metricFindStream.pipe(map((results) => ({ data: results }))); + } + + toDataQuery(query: StandardVariableQuery): PromQuery { + return { + refId: 'PrometheusDatasource-VariableQuery', + expr: query.query, + }; + } +} diff --git a/public/app/plugins/datasource/tempo/CheatSheet.tsx b/public/app/plugins/datasource/tempo/CheatSheet.tsx new file mode 100644 index 0000000..a037c9e --- /dev/null +++ b/public/app/plugins/datasource/tempo/CheatSheet.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function CheatSheet() { + return ( +
    +

    Tempo Cheat Sheet

    +

    + Tempo is a trace id lookup store. Enter a trace id in the above field and hit “Run Query” to retrieve your + trace. Tempo is generally paired with other datasources such as Loki or Prometheus to find traces. +

    +

    + Here are some{' '} + + instrumentation examples + {' '} + to get you started with trace discovery through logs and metrics (exemplars). +

    +
    + ); +} diff --git a/public/app/plugins/datasource/tempo/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/ConfigEditor.tsx new file mode 100644 index 0000000..31c0a99 --- /dev/null +++ b/public/app/plugins/datasource/tempo/ConfigEditor.tsx @@ -0,0 +1,21 @@ +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { TraceToLogsSettings } from 'app/core/components/TraceToLogsSettings'; +import React from 'react'; + +export type Props = DataSourcePluginOptionsEditorProps; + +export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => { + return ( + <> + + + + + ); +}; diff --git a/public/app/plugins/datasource/tempo/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryField.tsx new file mode 100644 index 0000000..f8b96a4 --- /dev/null +++ b/public/app/plugins/datasource/tempo/QueryField.tsx @@ -0,0 +1,124 @@ +import { DataQuery, DataSourceApi, ExploreQueryFieldProps } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { InlineField, InlineFieldRow, InlineLabel, LegacyForms, RadioButtonGroup } from '@grafana/ui'; +import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings'; +import React from 'react'; +import { LokiQueryField } from '../loki/components/LokiQueryField'; +import { TempoDatasource, TempoQuery, TempoQueryType } from './datasource'; + +type Props = ExploreQueryFieldProps; +const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceId'; +interface State { + linkedDatasource?: DataSourceApi; +} +export class TempoQueryField extends React.PureComponent { + state = { + linkedDatasource: undefined, + }; + linkedQuery: DataQuery; + constructor(props: Props) { + super(props); + this.linkedQuery = { refId: 'linked' }; + } + + async componentDidMount() { + const { datasource } = this.props; + // Find query field from linked datasource + const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {}; + const linkedDatasourceUid = tracesToLogsOptions.datasourceUid; + if (linkedDatasourceUid) { + const dsSrv = getDataSourceSrv(); + const linkedDatasource = await dsSrv.get(linkedDatasourceUid); + this.setState({ + linkedDatasource, + }); + } + } + + onChangeLinkedQuery = (value: DataQuery) => { + const { query, onChange } = this.props; + this.linkedQuery = value; + onChange({ + ...query, + linkedQuery: this.linkedQuery, + }); + }; + + onRunLinkedQuery = () => { + this.props.onRunQuery(); + }; + + render() { + const { query, onChange, range } = this.props; + const { linkedDatasource } = this.state; + + const absoluteTimeRange = { from: range!.from!.valueOf(), to: range!.to!.valueOf() }; // Range here is never optional + + return ( + <> + + + + options={[ + { value: 'search', label: 'Search' }, + { value: 'traceId', label: 'TraceID' }, + ]} + value={query.queryType || DEFAULT_QUERY_TYPE} + onChange={(v) => + onChange({ + ...query, + queryType: v, + }) + } + size="md" + /> + + + {query.queryType === 'search' && linkedDatasource && ( + <> + + Tempo uses {((linkedDatasource as unknown) as DataSourceApi).name} to find traces. + + + + + )} + {query.queryType === 'search' && !linkedDatasource && ( +
    Please set up a Traces-to-logs datasource in the datasource settings.
    + )} + {query.queryType !== 'search' && ( + +
    + + onChange({ + ...query, + query: e.currentTarget.value, + queryType: 'traceId', + linkedQuery: undefined, + }) + } + /> +
    + + } + /> + )} + + ); + } +} diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts new file mode 100644 index 0000000..eaad824 --- /dev/null +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -0,0 +1,103 @@ +import { DataFrame, dataFrameToJSON, DataSourceInstanceSettings, MutableDataFrame, PluginType } from '@grafana/data'; +import { Observable, of } from 'rxjs'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { TempoDatasource } from './datasource'; +import { FetchResponse, setBackendSrv, BackendDataSourceResponse } from '@grafana/runtime'; + +describe('Tempo data source', () => { + it('parses json fields from backend', async () => { + setupBackendSrv( + new MutableDataFrame({ + fields: [ + { name: 'traceID', values: ['04450900759028499335'] }, + { name: 'spanID', values: ['4322526419282105830'] }, + { name: 'parentSpanID', values: [''] }, + { name: 'operationName', values: ['store.validateQueryTimeRange'] }, + { name: 'startTime', values: [1619712655875.4539] }, + { name: 'duration', values: [14.984] }, + { name: 'serviceTags', values: ['{"key":"servicetag1","value":"service"}'] }, + { name: 'logs', values: ['{"timestamp":12345,"fields":[{"key":"count","value":1}]}'] }, + { name: 'tags', values: ['{"key":"tag1","value":"val1"}'] }, + { name: 'serviceName', values: ['service'] }, + ], + }) + ); + const ds = new TempoDatasource(defaultSettings); + const response = await ds.query({ targets: [{ refId: 'refid1' }] } as any).toPromise(); + + expect( + (response.data[0] as DataFrame).fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchObject([ + { name: 'traceID', values: ['04450900759028499335'] }, + { name: 'spanID', values: ['4322526419282105830'] }, + { name: 'parentSpanID', values: [''] }, + { name: 'operationName', values: ['store.validateQueryTimeRange'] }, + { name: 'startTime', values: [1619712655875.4539] }, + { name: 'duration', values: [14.984] }, + { name: 'serviceTags', values: [{ key: 'servicetag1', value: 'service' }] }, + { name: 'logs', values: [{ timestamp: 12345, fields: [{ key: 'count', value: 1 }] }] }, + { name: 'tags', values: [{ key: 'tag1', value: 'val1' }] }, + { name: 'serviceName', values: ['service'] }, + ]); + + expect( + (response.data[1] as DataFrame).fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchObject([ + { name: 'id', values: ['4322526419282105830'] }, + { name: 'title', values: ['service'] }, + { name: 'subTitle', values: ['store.validateQueryTimeRange'] }, + { name: 'mainStat', values: ['total: 14.98ms (100%)'] }, + { name: 'secondaryStat', values: ['self: 14.98ms (100%)'] }, + { name: 'color', values: [1.000007560204647] }, + ]); + + expect( + (response.data[2] as DataFrame).fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchObject([ + { name: 'id', values: [] }, + { name: 'target', values: [] }, + { name: 'source', values: [] }, + ]); + }); +}); + +function setupBackendSrv(frame: DataFrame) { + setBackendSrv({ + fetch(): Observable> { + return of( + createFetchResponse({ + results: { + refid1: { + frames: [dataFrameToJSON(frame)], + }, + }, + }) + ); + }, + } as any); +} + +const defaultSettings: DataSourceInstanceSettings = { + id: 0, + uid: '0', + type: 'tracing', + name: 'jaeger', + meta: { + id: 'jaeger', + name: 'jaeger', + type: PluginType.datasource, + info: {} as any, + module: '', + baseUrl: '', + }, + jsonData: {}, +}; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts new file mode 100644 index 0000000..4fd53c6 --- /dev/null +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -0,0 +1,106 @@ +import { + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, +} from '@grafana/data'; +import { DataSourceWithBackend } from '@grafana/runtime'; +import { TraceToLogsData, TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { merge, Observable, throwError } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { LokiOptions } from '../loki/types'; +import { transformTrace, transformTraceList } from './resultTransformer'; + +export type TempoQueryType = 'search' | 'traceId'; + +export type TempoQuery = { + query: string; + // Query to find list of traces, e.g., via Loki + linkedQuery?: DataQuery; + queryType: TempoQueryType; +} & DataQuery; + +export class TempoDatasource extends DataSourceWithBackend { + tracesToLogs: TraceToLogsOptions; + linkedDatasource: DataSourceApi; + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + this.tracesToLogs = instanceSettings.jsonData.tracesToLogs || {}; + if (this.tracesToLogs.datasourceUid) { + this.linkDatasource(); + } + } + + async linkDatasource() { + const dsSrv = getDatasourceSrv(); + this.linkedDatasource = await dsSrv.get(this.tracesToLogs.datasourceUid); + } + + query(options: DataQueryRequest): Observable { + const subQueries: Array> = []; + const filteredTargets = options.targets.filter((target) => !target.hide); + const searchTargets = filteredTargets.filter((target) => target.queryType === 'search'); + const traceTargets = filteredTargets.filter( + (target) => target.queryType === 'traceId' || target.queryType === undefined + ); + + // Run search queries on linked datasource + if (this.linkedDatasource && searchTargets.length > 0) { + // Wrap linked query into a data request based on original request + const linkedRequest: DataQueryRequest = { ...options, targets: searchTargets.map((t) => t.linkedQuery!) }; + // Find trace matchers in derived fields of the linked datasource that's identical to this datasource + const settings: DataSourceInstanceSettings = (this.linkedDatasource as any).instanceSettings; + const traceLinkMatcher: string[] = + settings.jsonData.derivedFields + ?.filter((field) => field.datasourceUid === this.uid && field.matcherRegex) + .map((field) => field.matcherRegex) || []; + if (!traceLinkMatcher || traceLinkMatcher.length === 0) { + subQueries.push( + throwError( + 'No Loki datasource configured for search. Set up Derived Fields for traces in a Loki datasource settings and link it to this Tempo datasource.' + ) + ); + } else { + subQueries.push( + (this.linkedDatasource.query(linkedRequest) as Observable).pipe( + map((response) => + response.error ? response : transformTraceList(response, this.uid, this.name, traceLinkMatcher) + ) + ) + ); + } + } + + if (traceTargets.length > 0) { + const traceRequest: DataQueryRequest = { ...options, targets: traceTargets }; + subQueries.push( + super.query(traceRequest).pipe( + map((response) => { + if (response.error) { + return response; + } + return transformTrace(response); + }) + ) + ); + } + + return merge(...subQueries); + } + + async testDatasource(): Promise { + const response = await super.query({ targets: [{ query: '', refId: 'A' }] } as any).toPromise(); + + if (!response.error?.message?.startsWith('failed to get trace')) { + return { status: 'error', message: 'Data source is not working' }; + } + + return { status: 'success', message: 'Data source is working' }; + } + + getQueryDisplayText(query: TempoQuery) { + return query.query; + } +} diff --git a/public/app/plugins/datasource/tempo/graphTransform.test.ts b/public/app/plugins/datasource/tempo/graphTransform.test.ts new file mode 100644 index 0000000..bff6dae --- /dev/null +++ b/public/app/plugins/datasource/tempo/graphTransform.test.ts @@ -0,0 +1,83 @@ +import { createGraphFrames } from './graphTransform'; +import { bigResponse } from './testResponse'; +import { DataFrameView, MutableDataFrame } from '@grafana/data'; + +describe('createGraphFrames', () => { + it('transforms basic response into nodes and edges frame', async () => { + const frames = createGraphFrames(bigResponse); + expect(frames.length).toBe(2); + expect(frames[0].length).toBe(30); + expect(frames[1].length).toBe(29); + + let view = new DataFrameView(frames[0]); + expect(view.get(0)).toMatchObject({ + id: '4322526419282105830', + title: 'loki-all', + subTitle: 'store.validateQueryTimeRange', + mainStat: 'total: 0ms (0.02%)', + secondaryStat: 'self: 0ms (100%)', + color: 0.00021968356127648162, + }); + + expect(view.get(29)).toMatchObject({ + id: '4450900759028499335', + title: 'loki-all', + subTitle: 'HTTP GET - loki_api_v1_query_range', + mainStat: 'total: 18.21ms (100%)', + secondaryStat: 'self: 3.22ms (17.71%)', + color: 0.17707117189595056, + }); + + view = new DataFrameView(frames[1]); + expect(view.get(28)).toMatchObject({ + id: '4450900759028499335--4790760741274015949', + }); + }); + + it('handles single span response', async () => { + const frames = createGraphFrames(singleSpanResponse); + expect(frames.length).toBe(2); + expect(frames[0].length).toBe(1); + + const view = new DataFrameView(frames[0]); + expect(view.get(0)).toMatchObject({ + id: '4322526419282105830', + title: 'loki-all', + subTitle: 'store.validateQueryTimeRange', + mainStat: 'total: 14.98ms (100%)', + secondaryStat: 'self: 14.98ms (100%)', + color: 1.000007560204647, + }); + }); + + it('handles missing spans', async () => { + const frames = createGraphFrames(missingSpanResponse); + expect(frames.length).toBe(2); + expect(frames[0].length).toBe(2); + expect(frames[1].length).toBe(0); + }); +}); + +const singleSpanResponse = new MutableDataFrame({ + fields: [ + { name: 'traceID', values: ['04450900759028499335'] }, + { name: 'spanID', values: ['4322526419282105830'] }, + { name: 'parentSpanID', values: [''] }, + { name: 'operationName', values: ['store.validateQueryTimeRange'] }, + { name: 'serviceName', values: ['loki-all'] }, + { name: 'startTime', values: [1619712655875.4539] }, + { name: 'duration', values: [14.984] }, + ], +}); + +const missingSpanResponse = new MutableDataFrame({ + fields: [ + { name: 'traceID', values: ['04450900759028499335', '04450900759028499335'] }, + { name: 'spanID', values: ['1', '2'] }, + { name: 'parentSpanID', values: ['', '3'] }, + { name: 'operationName', values: ['store.validateQueryTimeRange', 'store.validateQueryTimeRange'] }, + { name: 'serviceName', values: ['loki-all', 'loki-all'] }, + { name: 'startTime', values: [1619712655875.4539, 1619712655880.4539] }, + { name: 'duration', values: [14.984, 4.984] }, + ], +}); diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts new file mode 100644 index 0000000..eefa0f4 --- /dev/null +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -0,0 +1,205 @@ +import { + DataFrame, + DataFrameView, + FieldType, + MutableDataFrame, + NodeGraphDataFrameFieldNames as Fields, +} from '@grafana/data'; + +interface Row { + traceID: string; + spanID: string; + parentSpanID: string; + operationName: string; + serviceName: string; + serviceTags: string; + startTime: number; + duration: number; + logs: string; + tags: string; +} + +interface Node { + [Fields.id]: string; + [Fields.title]: string; + [Fields.subTitle]: string; + [Fields.mainStat]: string; + [Fields.secondaryStat]: string; + [Fields.color]: number; +} + +interface Edge { + [Fields.id]: string; + [Fields.target]: string; + [Fields.source]: string; +} + +export function createGraphFrames(data: DataFrame): DataFrame[] { + const { nodes, edges } = convertTraceToGraph(data); + + const nodesFrame = new MutableDataFrame({ + fields: [ + { name: Fields.id, type: FieldType.string }, + { name: Fields.title, type: FieldType.string }, + { name: Fields.subTitle, type: FieldType.string }, + { name: Fields.mainStat, type: FieldType.string }, + { name: Fields.secondaryStat, type: FieldType.string }, + { name: Fields.color, type: FieldType.number, config: { color: { mode: 'continuous-GrYlRd' } } }, + ], + meta: { + preferredVisualisationType: 'nodeGraph', + }, + }); + + for (const node of nodes) { + nodesFrame.add(node); + } + + const edgesFrame = new MutableDataFrame({ + fields: [ + { name: Fields.id, type: FieldType.string }, + { name: Fields.target, type: FieldType.string }, + { name: Fields.source, type: FieldType.string }, + ], + meta: { + preferredVisualisationType: 'nodeGraph', + }, + }); + + for (const edge of edges) { + edgesFrame.add(edge); + } + + return [nodesFrame, edgesFrame]; +} + +function convertTraceToGraph(data: DataFrame): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = []; + const edges: Edge[] = []; + + const view = new DataFrameView(data); + + const traceDuration = findTraceDuration(view); + const spanMap = makeSpanMap(view); + + for (let i = 0; i < view.length; i++) { + const row = view.get(i); + + const childrenDuration = getDuration(spanMap[row.spanID].children.map((c) => spanMap[c].span)); + const selfDuration = row.duration - childrenDuration; + + nodes.push({ + [Fields.id]: row.spanID, + [Fields.title]: row.serviceName ?? '', + [Fields.subTitle]: row.operationName, + [Fields.mainStat]: `total: ${toFixedNoTrailingZeros(row.duration)}ms (${toFixedNoTrailingZeros( + (row.duration / traceDuration) * 100 + )}%)`, + [Fields.secondaryStat]: `self: ${toFixedNoTrailingZeros(selfDuration)}ms (${toFixedNoTrailingZeros( + (selfDuration / row.duration) * 100 + )}%)`, + [Fields.color]: selfDuration / traceDuration, + }); + + // Sometimes some span can be missing. Don't add edges for those. + if (row.parentSpanID && spanMap[row.parentSpanID].span) { + edges.push({ + [Fields.id]: row.parentSpanID + '--' + row.spanID, + [Fields.target]: row.spanID, + [Fields.source]: row.parentSpanID, + }); + } + } + + return { nodes, edges }; +} + +function toFixedNoTrailingZeros(n: number) { + return parseFloat(n.toFixed(2)); +} + +/** + * Get the duration of the whole trace as it isn't a part of the response data. + * Note: Seems like this should be the same as just longest span, but this is probably safer. + */ +function findTraceDuration(view: DataFrameView): number { + let traceEndTime = 0; + let traceStartTime = Infinity; + + for (let i = 0; i < view.length; i++) { + const row = view.get(i); + + if (row.startTime < traceStartTime) { + traceStartTime = row.startTime; + } + + if (row.startTime + row.duration > traceEndTime) { + traceEndTime = row.startTime + row.duration; + } + } + + return traceEndTime - traceStartTime; +} + +/** + * Returns a map of the spans with children array for easier processing. It will also contain empty spans in case + * span is missing but other spans are it's children. + */ +function makeSpanMap(view: DataFrameView): { [id: string]: { span: Row; children: string[] } } { + const spanMap: { [id: string]: { span?: Row; children: string[] } } = {}; + + for (let i = 0; i < view.length; i++) { + const row = view.get(i); + + if (!spanMap[row.spanID]) { + spanMap[row.spanID] = { + // Need copy because of how the view works + span: { ...row }, + children: [], + }; + } else { + spanMap[row.spanID].span = { ...row }; + } + if (!spanMap[row.parentSpanID]) { + spanMap[row.parentSpanID] = { + span: undefined, + children: [row.spanID], + }; + } else { + spanMap[row.parentSpanID].children.push(row.spanID); + } + } + return spanMap as { [id: string]: { span: Row; children: string[] } }; +} + +/** + * Get non overlapping duration of the spans. + */ +function getDuration(rows: Row[]): number { + const ranges = rows.map<[number, number]>((r) => [r.startTime, r.startTime + r.duration]); + ranges.sort((a, b) => a[0] - b[0]); + const mergedRanges = ranges.reduce((acc, range) => { + if (!acc.length) { + return [range]; + } + const tail = acc.slice(-1)[0]; + const [prevStart, prevEnd] = tail; + const [start, end] = range; + if (end < prevEnd) { + // In this case the range is completely inside the prev range so we can just ignore it. + return acc; + } + + if (start > prevEnd) { + // There is no overlap so we can just add it to stack + return [...acc, range]; + } + + // We know there is overlap and current range ends later than previous so we can just extend the range + return [...acc.slice(0, -1), [prevStart, end]] as Array<[number, number]>; + }, [] as Array<[number, number]>); + + return mergedRanges.reduce((acc, range) => { + return acc + (range[1] - range[0]); + }, 0); +} diff --git a/public/app/plugins/datasource/tempo/img/tempo_logo.svg b/public/app/plugins/datasource/tempo/img/tempo_logo.svg new file mode 100644 index 0000000..c1f0ea7 --- /dev/null +++ b/public/app/plugins/datasource/tempo/img/tempo_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/datasource/tempo/module.ts b/public/app/plugins/datasource/tempo/module.ts new file mode 100644 index 0000000..b2b612f --- /dev/null +++ b/public/app/plugins/datasource/tempo/module.ts @@ -0,0 +1,10 @@ +import { DataSourcePlugin } from '@grafana/data'; +import CheatSheet from './CheatSheet'; +import { ConfigEditor } from './ConfigEditor'; +import { TempoDatasource } from './datasource'; +import { TempoQueryField } from './QueryField'; + +export const plugin = new DataSourcePlugin(TempoDatasource) + .setConfigEditor(ConfigEditor) + .setQueryEditorHelp(CheatSheet) + .setExploreQueryField(TempoQueryField); diff --git a/public/app/plugins/datasource/tempo/plugin.json b/public/app/plugins/datasource/tempo/plugin.json new file mode 100644 index 0000000..4451adc --- /dev/null +++ b/public/app/plugins/datasource/tempo/plugin.json @@ -0,0 +1,31 @@ +{ + "type": "datasource", + "name": "Tempo", + "id": "tempo", + "category": "tracing", + + "metrics": false, + "alerting": false, + "annotations": false, + "logs": false, + "streaming": false, + "tracing": true, + + "info": { + "description": "High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/tempo_logo.svg", + "large": "img/tempo_logo.svg" + }, + "links": [ + { + "name": "GitHub Project", + "url": "https://github.com/grafana/tempo" + } + ] + } +} diff --git a/public/app/plugins/datasource/tempo/resultTransformer.test.ts b/public/app/plugins/datasource/tempo/resultTransformer.test.ts new file mode 100644 index 0000000..c73b794 --- /dev/null +++ b/public/app/plugins/datasource/tempo/resultTransformer.test.ts @@ -0,0 +1,37 @@ +import { FieldType, MutableDataFrame } from '@grafana/data'; +import { createTableFrame } from './resultTransformer'; + +describe('transformTraceList()', () => { + const lokiDataFrame = new MutableDataFrame({ + fields: [ + { + name: 'ts', + type: FieldType.time, + values: ['2020-02-12T15:05:14.265Z', '2020-02-12T15:05:15.265Z', '2020-02-12T15:05:16.265Z'], + }, + { + name: 'line', + type: FieldType.string, + values: [ + 't=2020-02-12T15:04:51+0000 lvl=info msg="Starting Grafana" logger=server', + 't=2020-02-12T15:04:52+0000 lvl=info msg="Starting Grafana" logger=server traceID=asdfa1234', + 't=2020-02-12T15:04:53+0000 lvl=info msg="Starting Grafana" logger=server traceID=asdf88', + ], + }, + ], + meta: { + preferredVisualisationType: 'table', + }, + }); + + test('extracts traceIDs from log lines', () => { + const frame = createTableFrame(lokiDataFrame, 't1', 'tempo', ['traceID=(\\w+)', 'traceID=(\\w\\w)']); + expect(frame.fields[0].name).toBe('Time'); + expect(frame.fields[0].values.get(0)).toBe('2020-02-12T15:05:15.265Z'); + expect(frame.fields[1].name).toBe('traceID'); + expect(frame.fields[1].values.get(0)).toBe('asdfa1234'); + // Second match in new line + expect(frame.fields[0].values.get(1)).toBe('2020-02-12T15:05:15.265Z'); + expect(frame.fields[1].values.get(1)).toBe('as'); + }); +}); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts new file mode 100644 index 0000000..9a0b7e6 --- /dev/null +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -0,0 +1,150 @@ +import { DataQueryResponse, ArrayVector, DataFrame, Field, FieldType, MutableDataFrame } from '@grafana/data'; +import { createGraphFrames } from './graphTransform'; + +export function createTableFrame( + logsFrame: DataFrame, + datasourceUid: string, + datasourceName: string, + traceRegexs: string[] +): DataFrame { + const tableFrame = new MutableDataFrame({ + fields: [ + { + name: 'Time', + type: FieldType.time, + }, + { + name: 'traceID', + type: FieldType.string, + config: { + displayNameFromDS: 'Trace ID', + links: [ + { + title: 'Click to open trace ${__value.raw}', + url: '', + internal: { + datasourceUid, + datasourceName, + query: { + query: '${__value.raw}', + }, + }, + }, + ], + }, + }, + { + name: 'Message', + type: FieldType.string, + }, + ], + meta: { + preferredVisualisationType: 'table', + }, + }); + + if (!logsFrame || traceRegexs.length === 0) { + return tableFrame; + } + + const timeField = logsFrame.fields.find((f) => f.type === FieldType.time); + + // Going through all string fields to look for trace IDs + for (let field of logsFrame.fields) { + let hasMatch = false; + if (field.type === FieldType.string) { + const values = field.values.toArray(); + for (let i = 0; i < values.length; i++) { + const line = values[i]; + if (line) { + for (let traceRegex of traceRegexs) { + const match = (line as string).match(traceRegex); + if (match) { + const traceId = match[1]; + const time = timeField ? timeField.values.get(i) : null; + tableFrame.fields[0].values.add(time); + tableFrame.fields[1].values.add(traceId); + tableFrame.fields[2].values.add(line); + hasMatch = true; + } + } + } + } + } + if (hasMatch) { + break; + } + } + + return tableFrame; +} + +export function transformTraceList( + response: DataQueryResponse, + datasourceId: string, + datasourceName: string, + traceRegexs: string[] +): DataQueryResponse { + const frame = createTableFrame(response.data[0], datasourceId, datasourceName, traceRegexs); + response.data[0] = frame; + return response; +} + +export function transformTrace(response: DataQueryResponse): DataQueryResponse { + // We need to parse some of the fields which contain stringified json. + // Seems like we can't just map the values as the frame we got from backend has some default processing + // and will stringify the json back when we try to set it. So we create a new field and swap it instead. + const frame: DataFrame = response.data[0]; + + if (!frame) { + return emptyDataQueryResponse; + } + + parseJsonFields(frame); + + return { + ...response, + data: [...response.data, ...createGraphFrames(frame)], + }; +} + +/** + * Change fields which are json string into JS objects. Modifies the frame in place. + */ +function parseJsonFields(frame: DataFrame) { + for (const fieldName of ['serviceTags', 'logs', 'tags']) { + const field = frame.fields.find((f) => f.name === fieldName); + if (field) { + const fieldIndex = frame.fields.indexOf(field); + const values = new ArrayVector(); + const newField: Field = { + ...field, + values, + type: FieldType.other, + }; + + for (let i = 0; i < field.values.length; i++) { + const value = field.values.get(i); + values.set(i, value === '' ? undefined : JSON.parse(value)); + } + frame.fields[fieldIndex] = newField; + } + } +} + +const emptyDataQueryResponse = { + data: [ + new MutableDataFrame({ + fields: [ + { + name: 'trace', + type: FieldType.trace, + values: [], + }, + ], + meta: { + preferredVisualisationType: 'trace', + }, + }), + ], +}; diff --git a/public/app/plugins/datasource/tempo/testResponse.ts b/public/app/plugins/datasource/tempo/testResponse.ts new file mode 100644 index 0000000..adc86be --- /dev/null +++ b/public/app/plugins/datasource/tempo/testResponse.ts @@ -0,0 +1,1850 @@ +import { MutableDataFrame } from '@grafana/data'; + +export const bigResponse = new MutableDataFrame({ + fields: [ + { + name: 'traceID', + values: [ + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + '04450900759028499335', + ], + }, + { + name: 'spanID', + values: [ + '4322526419282105830', + '3095626263385822295', + '6397320272727147889', + '1853508259384889601', + '835290848278351608', + '5850531882244692375', + '663601651643245598', + '7501257416198979329', + '197793019475688138', + '1615811285828848458', + '4574624098918415850', + '7514953483465123028', + '8952478937948910109', + '4807320274580307678', + '3568196851335088191', + '8875894577931816561', + '797350946023907600', + '3198122728676260175', + '2528859115168229623', + '2108752984455624810', + '4343095775387093037', + '8751049139444653283', + '7753188085660872705', + '879502345235818407', + '3139747016493009985', + '7783241608507301178', + '1799838932837912875', + '4045260340056550643', + '4790760741274015949', + '4450900759028499335', + ], + }, + { + name: 'parentSpanID', + values: [ + '3095626263385822295', + '3198122728676260175', + '7501257416198979329', + '663601651643245598', + '5850531882244692375', + '663601651643245598', + '7501257416198979329', + '197793019475688138', + '1615811285828848458', + '3198122728676260175', + '8875894577931816561', + '3568196851335088191', + '4807320274580307678', + '3568196851335088191', + '8875894577931816561', + '797350946023907600', + '3198122728676260175', + '1799838932837912875', + '4343095775387093037', + '4343095775387093037', + '8751049139444653283', + '1799838932837912875', + '3139747016493009985', + '3139747016493009985', + '7783241608507301178', + '1799838932837912875', + '4045260340056550643', + '4790760741274015949', + '4450900759028499335', + '', + ], + }, + { + name: 'operationName', + values: [ + 'store.validateQueryTimeRange', + 'store.validateQuery', + 'cachingIndexClient.cacheFetch', + 'Shipper.Uploads.Query', + 'Shipper.Downloads.Table.MultiQueries', + 'Shipper.Downloads.Query', + 'QUERY', + 'store.lookupEntriesByQueries', + 'Store.lookupIdsByMetricNameMatcher', + 'SeriesStore.lookupSeriesByMetricNameMatchers', + 'cachingIndexClient.cacheFetch', + 'Shipper.Uploads.Query', + 'Shipper.Downloads.Table.MultiQueries', + 'Shipper.Downloads.Query', + 'QUERY', + 'store.lookupEntriesByQueries', + 'SeriesStore.lookupChunksBySeries', + 'SeriesStore.GetChunkRefs', + 'Fetcher.processCacheResponse', + 'GetParallelChunks', + 'ChunkStore.FetchChunks', + 'LokiStore.fetchLazyChunks', + 'Fetcher.processCacheResponse', + 'GetParallelChunks', + 'ChunkStore.FetchChunks', + 'LokiStore.fetchLazyChunks', + '/logproto.Querier/Query', + '/logproto.Querier/Query', + 'query.Exec', + 'HTTP GET - loki_api_v1_query_range', + ], + }, + { + name: 'serviceName', + values: [ + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + 'loki-all', + ], + }, + { + name: 'serviceTags', + values: [ + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + [ + { + value: 'loki-all', + key: 'service.name', + }, + { + value: 'Jaeger-Go-2.25.0', + key: 'opencensus.exporterversion', + }, + { + value: '708c78ea08c1', + key: 'host.hostname', + }, + { + value: '172.18.0.3', + key: 'ip', + }, + { + value: '632583de9a4a497b', + key: 'client-uuid', + }, + ], + ], + }, + { + name: 'startTime', + values: [ + 1619712655875.4539, + 1619712655875.4502, + 1619712655875.592, + 1619712655875.653, + 1619712655875.731, + 1619712655875.712, + 1619712655875.6428, + 1619712655875.5771, + 1619712655875.5168, + 1619712655875.488, + 1619712655875.939, + 1619712655875.959, + 1619712655876.0051, + 1619712655875.991, + 1619712655875.9539, + 1619712655875.9338, + 1619712655875.917, + 1619712655875.442, + 1619712655876.365, + 1619712655876.3809, + 1619712655876.359, + 1619712655876.331, + 1619712655876.62, + 1619712655876.629, + 1619712655876.616, + 1619712655876.592, + 1619712655875.052, + 1619712655874.819, + 1619712655874.7021, + 1619712655874.591, + ], + }, + { + name: 'duration', + values: [ + 0.004, + 0.016, + 0.039, + 0.047, + 0.063, + 0.087, + 0.163, + 0.303, + 0.384, + 0.421, + 0.012, + 0.021, + 0.033, + 0.048, + 0.092, + 0.169, + 0.197, + 0.689, + 0.012, + 0.196, + 0.225, + 0.255, + 0.007, + 0.167, + 0.189, + 0.217, + 13.918, + 14.723, + 14.984, + 18.208, + ], + }, + { + name: 'logs', + values: [ + null, + null, + [ + { + timestamp: 1619712655875.631, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 0, + key: 'hits', + }, + { + value: 16, + key: 'misses', + }, + ], + }, + ], + null, + [ + { + timestamp: 1619712655875.738, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'index_18746', + key: 'table-name', + }, + { + value: 16, + key: 'query-count', + }, + ], + }, + { + timestamp: 1619712655875.773, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'compactor-1619711145.gz', + key: 'queried-db', + }, + ], + }, + { + timestamp: 1619712655875.794, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: '708c78ea08c1-1619516350042748959-1619711100.gz', + key: 'queried-db', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.719, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'index_18746', + key: 'table-name', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.7068, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'uploads-manager', + key: 'queried', + }, + ], + }, + { + timestamp: 1619712655875.803, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'downloads-manager', + key: 'queried', + }, + ], + }, + ], + null, + [ + { + timestamp: 1619712655875.536, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'logs', + key: 'metricName', + }, + { + value: 'compose_project="devenv"', + key: 'matcher', + }, + ], + }, + { + timestamp: 1619712655875.568, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'compose_project="devenv"', + key: 'matcher', + }, + { + value: 16, + key: 'queries', + }, + ], + }, + { + timestamp: 1619712655875.5762, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'compose_project="devenv"', + key: 'matcher', + }, + { + value: 16, + key: 'filteredQueries', + }, + ], + }, + { + timestamp: 1619712655875.892, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'compose_project="devenv"', + key: 'matcher', + }, + { + value: 2, + key: 'entries', + }, + ], + }, + { + timestamp: 1619712655875.9019, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'compose_project="devenv"', + key: 'matcher', + }, + { + value: 1, + key: 'ids', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.4958, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'logs', + key: 'metricName', + }, + { + value: 1, + key: 'matchers', + }, + ], + }, + { + timestamp: 1619712655875.9092, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'post intersection', + key: 'msg', + }, + { + value: 1, + key: 'ids', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.9512, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 0, + key: 'hits', + }, + { + value: 1, + key: 'misses', + }, + ], + }, + ], + null, + [ + { + timestamp: 1619712655876.012, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'index_18746', + key: 'table-name', + }, + { + value: 1, + key: 'query-count', + }, + ], + }, + { + timestamp: 1619712655876.031, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'compactor-1619711145.gz', + key: 'queried-db', + }, + ], + }, + { + timestamp: 1619712655876.0378, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: '708c78ea08c1-1619516350042748959-1619711100.gz', + key: 'queried-db', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.999, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'index_18746', + key: 'table-name', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.988, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'uploads-manager', + key: 'queried', + }, + ], + }, + { + timestamp: 1619712655876.0452, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'downloads-manager', + key: 'queried', + }, + ], + }, + ], + null, + [ + { + timestamp: 1619712655875.925, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 1, + key: 'seriesIDs', + }, + ], + }, + { + timestamp: 1619712655875.9329, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 1, + key: 'queries', + }, + ], + }, + { + timestamp: 1619712655876.1118, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 2, + key: 'entries', + }, + ], + }, + ], + [ + { + timestamp: 1619712655875.4849, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'logs', + key: 'metric', + }, + ], + }, + { + timestamp: 1619712655875.915, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 1, + key: 'series-ids', + }, + ], + }, + { + timestamp: 1619712655876.12, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 2, + key: 'chunk-ids', + }, + ], + }, + { + timestamp: 1619712655876.131, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 2, + key: 'chunks-post-filtering', + }, + ], + }, + ], + [ + { + timestamp: 1619712655876.375, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 1, + key: 'chunks', + }, + { + value: 0, + key: 'decodeRequests', + }, + { + value: 1, + key: 'missing', + }, + ], + }, + ], + [ + { + timestamp: 1619712655876.384, + fields: [ + { + value: 1, + key: 'chunks requested', + }, + ], + }, + { + timestamp: 1619712655876.577, + fields: [ + { + value: 1, + key: 'chunks fetched', + }, + ], + }, + ], + null, + [ + { + timestamp: 1619712655876.342, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'loading lazy chunks', + key: 'msg', + }, + { + value: 1, + key: 'chunks', + }, + ], + }, + ], + [ + { + timestamp: 1619712655876.627, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 1, + key: 'chunks', + }, + { + value: 0, + key: 'decodeRequests', + }, + { + value: 1, + key: 'missing', + }, + ], + }, + ], + [ + { + timestamp: 1619712655876.631, + fields: [ + { + value: 1, + key: 'chunks requested', + }, + ], + }, + { + timestamp: 1619712655876.795, + fields: [ + { + value: 1, + key: 'chunks fetched', + }, + ], + }, + ], + null, + [ + { + timestamp: 1619712655876.604, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 'loading lazy chunks', + key: 'msg', + }, + { + value: 1, + key: 'chunks', + }, + ], + }, + ], + null, + null, + [ + { + timestamp: 1619712655889.606, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: 1, + key: 'Ingester.TotalReached', + }, + { + value: 1, + key: 'Ingester.TotalChunksMatched', + }, + { + value: 0, + key: 'Ingester.TotalBatches', + }, + { + value: 0, + key: 'Ingester.TotalLinesSent', + }, + { + value: '47 kB', + key: 'Ingester.HeadChunkBytes', + }, + { + value: 424, + key: 'Ingester.HeadChunkLines', + }, + { + value: '219 kB', + key: 'Ingester.DecompressedBytes', + }, + { + value: 1679, + key: 'Ingester.DecompressedLines', + }, + { + value: '124 kB', + key: 'Ingester.CompressedBytes', + }, + { + value: 0, + key: 'Ingester.TotalDuplicates', + }, + { + value: 0, + key: 'Store.TotalChunksRef', + }, + { + value: 0, + key: 'Store.TotalChunksDownloaded', + }, + { + value: '0s', + key: 'Store.ChunksDownloadTime', + }, + { + value: '0 B', + key: 'Store.HeadChunkBytes', + }, + { + value: 0, + key: 'Store.HeadChunkLines', + }, + { + value: '0 B', + key: 'Store.DecompressedBytes', + }, + { + value: 0, + key: 'Store.DecompressedLines', + }, + { + value: '0 B', + key: 'Store.CompressedBytes', + }, + { + value: 0, + key: 'Store.TotalDuplicates', + }, + ], + }, + { + timestamp: 1619712655889.617, + fields: [ + { + value: 'debug', + key: 'level', + }, + { + value: '18 MB', + key: 'Summary.BytesProcessedPerSecond', + }, + { + value: 141753, + key: 'Summary.LinesProcessedPerSecond', + }, + { + value: '266 kB', + key: 'Summary.TotalBytesProcessed', + }, + { + value: 2103, + key: 'Summary.TotalLinesProcessed', + }, + { + value: '14.835651ms', + key: 'Summary.ExecTime', + }, + ], + }, + ], + null, + ], + }, + { + name: 'tags', + values: [ + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 'fake', + key: 'organization', + }, + { + value: 'client', + key: 'span.kind', + }, + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 'fake', + key: 'organization', + }, + { + value: 'client', + key: 'span.kind', + }, + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 'gRPC', + key: 'component', + }, + { + value: 'server', + key: 'span.kind', + }, + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 'gRPC', + key: 'component', + }, + { + value: 'client', + key: 'span.kind', + }, + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 0, + key: 'status.code', + }, + ], + [ + { + value: 'const', + key: 'sampler.type', + }, + { + value: true, + key: 'sampler.param', + }, + { + value: 200, + key: 'http.status_code', + }, + { + value: 'GET', + key: 'http.method', + }, + { + value: + '/loki/api/v1/query_range?direction=BACKWARD&limit=1000&query=%7Bcompose_project%3D%22devenv%22%7D&start=1619709055000000000&end=1619712656000000000&step=2', + key: 'http.url', + }, + { + value: 'net/http', + key: 'component', + }, + { + value: 'server', + key: 'span.kind', + }, + { + value: 0, + key: 'status.code', + }, + ], + ], + }, + ], +}); diff --git a/public/app/plugins/datasource/testdata/ConfigEditor.tsx b/public/app/plugins/datasource/testdata/ConfigEditor.tsx new file mode 100644 index 0000000..9cc3332 --- /dev/null +++ b/public/app/plugins/datasource/testdata/ConfigEditor.tsx @@ -0,0 +1,15 @@ +// Libraries +import React, { PureComponent } from 'react'; + +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; + +type Props = DataSourcePluginOptionsEditorProps; + +/** + * Empty Config Editor -- settings to save + */ +export class ConfigEditor extends PureComponent { + render() { + return
    ; + } +} diff --git a/public/app/plugins/datasource/testdata/LogIpsum.ts b/public/app/plugins/datasource/testdata/LogIpsum.ts new file mode 100644 index 0000000..a1afe70 --- /dev/null +++ b/public/app/plugins/datasource/testdata/LogIpsum.ts @@ -0,0 +1,162 @@ +import { LogLevel } from '@grafana/data'; + +let index = 0; + +export function getRandomLogLevel(): LogLevel { + const v = Math.random(); + if (v > 0.9) { + return LogLevel.critical; + } + if (v > 0.8) { + return LogLevel.error; + } + if (v > 0.7) { + return LogLevel.warning; + } + if (v > 0.4) { + return LogLevel.info; + } + if (v > 0.3) { + return LogLevel.debug; + } + if (v > 0.1) { + return LogLevel.trace; + } + return LogLevel.unknown; +} + +export function getNextWord() { + index = (index + Math.floor(Math.random() * 5)) % words.length; + return words[index]; +} + +export function getRandomLine(length = 60) { + let line = getNextWord(); + while (line.length < length) { + line += ' ' + getNextWord(); + } + return line; +} + +const words = [ + 'At', + 'vero', + 'eos', + 'et', + 'accusamus', + 'et', + 'iusto', + 'odio', + 'dignissimos', + 'ducimus', + 'qui', + 'blanditiis', + 'praesentium', + 'voluptatum', + 'deleniti', + 'atque', + 'corrupti', + 'quos', + 'dolores', + 'et', + 'quas', + 'molestias', + 'excepturi', + 'sint', + 'occaecati', + 'cupiditate', + 'non', + 'provident', + 'similique', + 'sunt', + 'in', + 'culpa', + 'qui', + 'officia', + 'deserunt', + 'mollitia', + 'animi', + 'id', + 'est', + 'laborum', + 'et', + 'dolorum', + 'fuga', + 'Et', + 'harum', + 'quidem', + 'rerum', + 'facilis', + 'est', + 'et', + 'expedita', + 'distinctio', + 'Nam', + 'libero', + 'tempore', + 'cum', + 'soluta', + 'nobis', + 'est', + 'eligendi', + 'optio', + 'cumque', + 'nihil', + 'impedit', + 'quo', + 'minus', + 'id', + 'quod', + 'maxime', + 'placeat', + 'facere', + 'possimus', + 'omnis', + 'voluptas', + 'assumenda', + 'est', + 'omnis', + 'dolor', + 'repellendus', + 'Temporibus', + 'autem', + 'quibusdam', + 'et', + 'aut', + 'officiis', + 'debitis', + 'aut', + 'rerum', + 'necessitatibus', + 'saepe', + 'eveniet', + 'ut', + 'et', + 'voluptates', + 'repudiandae', + 'sint', + 'et', + 'molestiae', + 'non', + 'recusandae', + 'Itaque', + 'earum', + 'rerum', + 'hic', + 'tenetur', + 'a', + 'sapiente', + 'delectus', + 'ut', + 'aut', + 'reiciendis', + 'voluptatibus', + 'maiores', + 'alias', + 'consequatur', + 'aut', + 'perferendis', + 'doloribus', + 'asperiores', + 'repellat', +]; diff --git a/public/app/plugins/datasource/testdata/QueryEditor.test.tsx b/public/app/plugins/datasource/testdata/QueryEditor.test.tsx new file mode 100644 index 0000000..dcc2bbb --- /dev/null +++ b/public/app/plugins/datasource/testdata/QueryEditor.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { defaultQuery } from './constants'; +import { QueryEditor, Props } from './QueryEditor'; +import { scenarios } from './__mocks__/scenarios'; +import { defaultStreamQuery } from './runStreams'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const mockOnChange = jest.fn(); +const props = { + onRunQuery: jest.fn(), + query: defaultQuery, + onChange: mockOnChange, + datasource: { + getScenarios: () => Promise.resolve(scenarios), + } as any, +}; + +const setup = (testProps?: Partial) => { + const editorProps = { ...props, ...testProps }; + return render(); +}; + +describe('Test Datasource Query Editor', () => { + it('should render with default scenario', async () => { + setup(); + + expect(await screen.findByText(/random walk/i)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Alias' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Labels' })).toBeInTheDocument(); + }); + + it('should switch scenario and display its default values', async () => { + const { rerender } = setup(); + + let select = (await screen.findByText('Scenario')).nextSibling!; + await fireEvent.keyDown(select, { keyCode: 40 }); + const scs = screen.getAllByLabelText('Select option'); + + expect(scs).toHaveLength(scenarios.length); + + await userEvent.click(screen.getByText('CSV Metric Values')); + expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ scenarioId: 'csv_metric_values' })); + await rerender( + + ); + expect(await screen.findByRole('textbox', { name: /string input/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /string input/i })).toHaveValue('1,20,90,30,5,0'); + + await fireEvent.keyDown(select, { keyCode: 40 }); + await userEvent.click(screen.getByText('Grafana API')); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ scenarioId: 'grafana_api', stringInput: 'datasources' }) + ); + rerender( + + ); + expect(await screen.findByText('Grafana API')).toBeInTheDocument(); + expect(screen.getByText('Data Sources')).toBeInTheDocument(); + + await fireEvent.keyDown(select, { keyCode: 40 }); + await userEvent.click(screen.getByText('Streaming Client')); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ scenarioId: 'streaming_client', stream: defaultStreamQuery }) + ); + + const streamQuery = { ...defaultQuery, stream: defaultStreamQuery, scenarioId: 'streaming_client' }; + + rerender(); + + expect(await screen.findByText('Streaming Client')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByLabelText('Noise')).toHaveValue(2.2); + expect(screen.getByLabelText('Speed (ms)')).toHaveValue(250); + expect(screen.getByLabelText('Spread')).toHaveValue(3.5); + expect(screen.getByLabelText('Bands')).toHaveValue(1); + }); +}); diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx new file mode 100644 index 0000000..1d31fcf --- /dev/null +++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx @@ -0,0 +1,260 @@ +// Libraries +import React, { ChangeEvent, FormEvent, useMemo } from 'react'; +import { useAsync } from 'react-use'; + +// Components +import { selectors as editorSelectors } from '@grafana/e2e-selectors'; +import { Input, InlineFieldRow, InlineField, Select, TextArea, InlineSwitch } from '@grafana/ui'; +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { StreamingClientEditor, ManualEntryEditor, RandomWalkEditor } from './components'; + +// Types +import { TestDataDataSource } from './datasource'; +import { TestDataQuery, Scenario, NodesQuery, CSVWave } from './types'; +import { PredictablePulseEditor } from './components/PredictablePulseEditor'; +import { CSVWavesEditor } from './components/CSVWaveEditor'; +import { defaultCSVWaveQuery, defaultPulseQuery, defaultQuery } from './constants'; +import { GrafanaLiveEditor } from './components/GrafanaLiveEditor'; +import { NodeGraphEditor } from './components/NodeGraphEditor'; +import { defaultStreamQuery } from './runStreams'; + +const showLabelsFor = ['random_walk', 'predictable_pulse']; +const endpoints = [ + { value: 'datasources', label: 'Data Sources' }, + { value: 'search', label: 'Search' }, + { value: 'annotations', label: 'Annotations' }, +]; + +const selectors = editorSelectors.components.DataSource.TestData.QueryTab; + +export interface EditorProps { + onChange: (value: any) => void; + query: TestDataQuery; +} + +export type Props = QueryEditorProps; + +export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) => { + query = { ...defaultQuery, ...query }; + + const { loading, value: scenarioList } = useAsync(async () => { + return datasource.getScenarios(); + }, []); + + const onUpdate = (query: TestDataQuery) => { + onChange(query); + onRunQuery(); + }; + + const currentScenario = useMemo(() => scenarioList?.find((scenario) => scenario.id === query.scenarioId), [ + scenarioList, + query, + ]); + const scenarioId = currentScenario?.id; + + const onScenarioChange = (item: SelectableValue) => { + const scenario = scenarioList?.find((sc) => sc.id === item.value); + + if (!scenario) { + return; + } + + // Clear model from existing props that belong to other scenarios + const update: TestDataQuery = { + scenarioId: item.value!, + refId: query.refId, + alias: query.alias, + }; + + if (scenario.stringInput) { + update.stringInput = scenario.stringInput; + } + + switch (scenario.id) { + case 'grafana_api': + update.stringInput = 'datasources'; + break; + case 'streaming_client': + update.stream = defaultStreamQuery; + break; + case 'live': + update.channel = 'random-2s-stream'; // default stream + break; + case 'predictable_pulse': + update.pulseWave = defaultPulseQuery; + break; + case 'predictable_csv_wave': + update.csvWave = defaultCSVWaveQuery; + break; + } + + onUpdate(update); + }; + + const onInputChange = (e: FormEvent) => { + const { name, value, type } = e.target as HTMLInputElement | HTMLTextAreaElement; + let newValue: any = value; + + if (type === 'number') { + newValue = Number(value); + } + + if (name === 'levelColumn') { + newValue = (e.target as HTMLInputElement).checked; + } + + onUpdate({ ...query, [name]: newValue }); + }; + + const onFieldChange = (field: string) => (e: ChangeEvent) => { + const { name, value, type } = e.target as HTMLInputElement; + let newValue: any = value; + + if (type === 'number') { + newValue = Number(value); + } + + onUpdate({ ...query, [field]: { ...(query as any)[field], [name]: newValue } }); + }; + + const onEndPointChange = ({ value }: SelectableValue) => { + onUpdate({ ...query, stringInput: value }); + }; + + const onStreamClientChange = onFieldChange('stream'); + const onPulseWaveChange = onFieldChange('pulseWave'); + + const onCSVWaveChange = (csvWave?: CSVWave[]) => { + onUpdate({ ...query, csvWave }); + }; + + const options = useMemo( + () => + (scenarioList || []) + .map((item) => ({ label: item.name, value: item.id })) + .sort((a, b) => a.label.localeCompare(b.label)), + [scenarioList] + ); + const showLabels = useMemo(() => showLabelsFor.includes(query.scenarioId), [query]); + + if (loading) { + return null; + } + + return ( + <> + + + + + )} + + + + {showLabels && ( + + Set labels using a key=value syntax: +
    + {`{ key = "value", key2 = "value" }`} +
    + key="value", key2="value" +
    + key=value, key2=value +
    + + } + > + +
    + )} +
    + + {scenarioId === 'manual_entry' && } + {scenarioId === 'random_walk' && } + {scenarioId === 'streaming_client' && } + {scenarioId === 'live' && } + {scenarioId === 'logs' && ( + + + + + + + + + )} + + {scenarioId === 'grafana_api' && ( + +